commit 4c75ff8eddfa7eb31618278a06c9c54203532c01 Author: Benjamyn Love Date: Wed Apr 27 08:42:19 2022 +1000 Initial diff --git a/boot_out.txt b/boot_out.txt new file mode 100644 index 0000000..b81dcfd --- /dev/null +++ b/boot_out.txt @@ -0,0 +1,2 @@ +Adafruit CircuitPython 7.1.1 on 2022-01-14; Adafruit KB2040 with rp2040 +Board ID:adafruit_kb2040 diff --git a/font5x8.bin b/font5x8.bin new file mode 100644 index 0000000..9a0563b Binary files /dev/null and b/font5x8.bin differ diff --git a/kmk/__init__.py b/kmk/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/kmk/consts.py b/kmk/consts.py new file mode 100644 index 0000000..b222ede --- /dev/null +++ b/kmk/consts.py @@ -0,0 +1,13 @@ +from micropython import const + +try: + from kmk.release_info import KMK_RELEASE +except Exception: + KMK_RELEASE = 'copied-from-git' + + +class UnicodeMode: + NOOP = const(0) + LINUX = IBUS = const(1) + MACOS = OSX = RALT = const(2) + WINC = const(3) diff --git a/kmk/extensions/__init__.py b/kmk/extensions/__init__.py new file mode 100644 index 0000000..1eba5e5 --- /dev/null +++ b/kmk/extensions/__init__.py @@ -0,0 +1,51 @@ +class InvalidExtensionEnvironment(Exception): + pass + + +class Extension: + _enabled = True + + def enable(self, keyboard): + self._enabled = True + + self.on_runtime_enable(keyboard) + + def disable(self, keyboard): + self._enabled = False + + self.on_runtime_disable(keyboard) + + # The below methods should be implemented by subclasses + + def on_runtime_enable(self, keyboard): + raise NotImplementedError + + def on_runtime_disable(self, keyboard): + raise NotImplementedError + + def during_bootup(self, keyboard): + raise NotImplementedError + + def before_matrix_scan(self, keyboard): + ''' + Return value will be injected as an extra matrix update + ''' + raise NotImplementedError + + def after_matrix_scan(self, keyboard): + ''' + Return value will be replace matrix update if supplied + ''' + raise NotImplementedError + + def before_hid_send(self, keyboard): + raise NotImplementedError + + def after_hid_send(self, keyboard): + raise NotImplementedError + + def on_powersave_enable(self, keyboard): + raise NotImplementedError + + def on_powersave_disable(self, keyboard): + raise NotImplementedError diff --git a/kmk/extensions/international.py b/kmk/extensions/international.py new file mode 100644 index 0000000..eab8087 --- /dev/null +++ b/kmk/extensions/international.py @@ -0,0 +1,59 @@ +'''Adds international keys''' +from kmk.extensions import Extension +from kmk.keys import make_key + + +class International(Extension): + '''Adds international keys''' + + def __init__(self): + # International + make_key(code=50, names=('NONUS_HASH', 'NUHS')) + make_key(code=100, names=('NONUS_BSLASH', 'NUBS')) + make_key(code=101, names=('APP', 'APPLICATION', 'SEL', 'WINMENU')) + + make_key(code=135, names=('INT1', 'RO')) + make_key(code=136, names=('INT2', 'KANA')) + make_key(code=137, names=('INT3', 'JYEN')) + make_key(code=138, names=('INT4', 'HENK')) + make_key(code=139, names=('INT5', 'MHEN')) + make_key(code=140, names=('INT6',)) + make_key(code=141, names=('INT7',)) + make_key(code=142, names=('INT8',)) + make_key(code=143, names=('INT9',)) + make_key(code=144, names=('LANG1', 'HAEN')) + make_key(code=145, names=('LANG2', 'HAEJ')) + make_key(code=146, names=('LANG3',)) + make_key(code=147, names=('LANG4',)) + make_key(code=148, names=('LANG5',)) + make_key(code=149, names=('LANG6',)) + make_key(code=150, names=('LANG7',)) + make_key(code=151, names=('LANG8',)) + make_key(code=152, names=('LANG9',)) + + def on_runtime_enable(self, sandbox): + return + + def on_runtime_disable(self, sandbox): + return + + def during_bootup(self, sandbox): + return + + def before_matrix_scan(self, sandbox): + return + + def after_matrix_scan(self, sandbox): + return + + def before_hid_send(self, sandbox): + return + + def after_hid_send(self, sandbox): + return + + def on_powersave_enable(self, sandbox): + return + + def on_powersave_disable(self, sandbox): + return diff --git a/kmk/extensions/keymap_extras/keymap_jp.py b/kmk/extensions/keymap_extras/keymap_jp.py new file mode 100644 index 0000000..f4355db --- /dev/null +++ b/kmk/extensions/keymap_extras/keymap_jp.py @@ -0,0 +1,34 @@ +# What's this? +# This is a keycode conversion script. With this, KMK will work as a JIS keyboard. + +# Usage +# ```python +# import kmk.extensions.keymap_extras.keymap_jp +# ``` + +from kmk.keys import KC + +KC.CIRC = KC.EQL # ^ +KC.AT = KC.LBRC # @ +KC.LBRC = KC.RBRC # [ +KC.EISU = KC.CAPS # Eisū (英数) +KC.COLN = KC.QUOT # : +KC.LCBR = KC.LSFT(KC.RBRC) # { +KC.RBRC = KC.NUHS # ] +KC.BSLS = KC.INT1 # (backslash) +KC.PLUS = KC.LSFT(KC.SCLN) +KC.TILD = KC.LSFT(KC.EQL) # ~ +KC.GRV = KC.LSFT(KC.AT) # ` +KC.DQUO = KC.LSFT(KC.N2) # " +KC.AMPR = KC.LSFT(KC.N6) # & +KC.ASTR = KC.LSFT(KC.QUOT) # * +KC.QUOT = KC.LSFT(KC.N7) # ' +KC.LPRN = KC.LSFT(KC.N8) # ( +KC.RPRN = KC.LSFT(KC.N9) # ) +KC.EQL = KC.LSFT(KC.MINS) # = +KC.PIPE = KC.LSFT(KC.INT3) # | +KC.RCBR = KC.LSFT(KC.NUHS) # } +KC.LABK = KC.LSFT(KC.COMM) # < +KC.RABK = KC.LSFT(KC.DOT) # > +KC.QUES = KC.LSFT(KC.SLSH) # ? +KC.UNDS = KC.LSFT(KC.INT1) # _ diff --git a/kmk/extensions/led.py b/kmk/extensions/led.py new file mode 100644 index 0000000..418ab6d --- /dev/null +++ b/kmk/extensions/led.py @@ -0,0 +1,256 @@ +import pwmio +from math import e, exp, pi, sin + +from kmk.extensions import Extension, InvalidExtensionEnvironment +from kmk.keys import make_argumented_key, make_key +from kmk.utils import clamp + + +class LEDKeyMeta: + def __init__(self, *leds): + self.leds = leds + self.brightness = None + + +class AnimationModes: + OFF = 0 + STATIC = 1 + STATIC_STANDBY = 2 + BREATHING = 3 + USER = 4 + + +class LED(Extension): + def __init__( + self, + led_pin, + brightness_step=5, + brightness_limit=100, + breathe_center=1.5, + animation_mode=AnimationModes.STATIC, + animation_speed=1, + user_animation=None, + val=100, + ): + try: + pins_iter = iter(led_pin) + except TypeError: + pins_iter = [led_pin] + + try: + self._leds = [pwmio.PWMOut(pin) for pin in pins_iter] + except Exception as e: + print(e) + raise InvalidExtensionEnvironment( + 'Unable to create pwmio.PWMOut() instance with provided led_pin' + ) + + self._brightness = 0 + self._pos = 0 + self._effect_init = False + self._enabled = True + + self.brightness_step = brightness_step + self.brightness_limit = brightness_limit + self.animation_mode = animation_mode + self.animation_speed = animation_speed + self.breathe_center = breathe_center + self.val = val + + if user_animation is not None: + self.user_animation = user_animation + + make_argumented_key( + names=('LED_TOG',), + validator=self._led_key_validator, + on_press=self._key_led_tog, + ) + make_argumented_key( + names=('LED_INC',), + validator=self._led_key_validator, + on_press=self._key_led_inc, + ) + make_argumented_key( + names=('LED_DEC',), + validator=self._led_key_validator, + on_press=self._key_led_dec, + ) + make_argumented_key( + names=('LED_SET',), + validator=self._led_set_key_validator, + on_press=self._key_led_set, + ) + make_key(names=('LED_ANI',), on_press=self._key_led_ani) + make_key(names=('LED_AND',), on_press=self._key_led_and) + make_key( + names=('LED_MODE_PLAIN', 'LED_M_P'), on_press=self._key_led_mode_static + ) + make_key( + names=('LED_MODE_BREATHE', 'LED_M_B'), on_press=self._key_led_mode_breathe + ) + + def __repr__(self): + return 'LED({})'.format(self._to_dict()) + + def _to_dict(self): + return { + '_brightness': self._brightness, + '_pos': self._pos, + 'brightness_step': self.brightness_step, + 'brightness_limit': self.brightness_limit, + 'animation_mode': self.animation_mode, + 'animation_speed': self.animation_speed, + 'breathe_center': self.breathe_center, + 'val': self.val, + } + + def on_runtime_enable(self, sandbox): + return + + def on_runtime_disable(self, sandbox): + return + + def during_bootup(self, sandbox): + return + + def before_matrix_scan(self, sandbox): + return + + def after_matrix_scan(self, sandbox): + return + + def before_hid_send(self, sandbox): + return + + def after_hid_send(self, sandbox): + if self._enabled and self.animation_mode: + self.animate() + return + + def on_powersave_enable(self, sandbox): + return + + def on_powersave_disable(self, sandbox): + return + + def _init_effect(self): + self._pos = 0 + self._effect_init = False + return self + + def set_brightness(self, percent, leds=None): + leds = leds or range(0, len(self._leds)) + for i in leds: + self._leds[i].duty_cycle = int(percent / 100 * 65535) + + def step_brightness(self, step, leds=None): + leds = leds or range(0, len(self._leds)) + for i in leds: + brightness = int(self._leds[i].duty_cycle / 65535 * 100) + step + self.set_brightness(clamp(brightness), [i]) + + def increase_brightness(self, step=None, leds=None): + if step is None: + step = self.brightness_step + self.step_brightness(step, leds) + + def decrease_brightness(self, step=None, leds=None): + if step is None: + step = self.brightness_step + self.step_brightness(-step, leds) + + def off(self): + self.set_brightness(0) + + def increase_ani(self): + ''' + Increases animation speed by 1 amount stopping at 10 + :param step: + ''' + if (self.animation_speed + 1) >= 10: + self.animation_speed = 10 + else: + self.val += 1 + + def decrease_ani(self): + ''' + Decreases animation speed by 1 amount stopping at 0 + :param step: + ''' + if (self.val - 1) <= 0: + self.val = 0 + else: + self.val -= 1 + + def effect_breathing(self): + # http://sean.voisen.org/blog/2011/10/breathing-led-with-arduino/ + # https://github.com/qmk/qmk_firmware/blob/9f1d781fcb7129a07e671a46461e501e3f1ae59d/quantum/rgblight.c#L806 + sined = sin((self._pos / 255.0) * pi) + multip_1 = exp(sined) - self.breathe_center / e + multip_2 = self.brightness_limit / (e - 1 / e) + + self._brightness = int(multip_1 * multip_2) + self._pos = (self._pos + self.animation_speed) % 256 + self.set_brightness(self._brightness) + + def effect_static(self): + self.set_brightness(self._brightness) + # Set animation mode to none to prevent cycles from being wasted + self.animation_mode = None + + def animate(self): + ''' + Activates a "step" in the animation based on the active mode + :return: Returns the new state in animation + ''' + if self._effect_init: + self._init_effect() + if self._enabled: + if self.animation_mode == AnimationModes.BREATHING: + return self.effect_breathing() + elif self.animation_mode == AnimationModes.STATIC: + return self.effect_static() + elif self.animation_mode == AnimationModes.USER: + return self.user_animation(self) + else: + self.off() + + def _led_key_validator(self, *leds): + if leds: + for led in leds: + assert self._leds[led] + return LEDKeyMeta(*leds) + + def _led_set_key_validator(self, brightness, *leds): + meta = self._led_key_validator(*leds) + meta.brightness = brightness + return meta + + def _key_led_tog(self, *args, **kwargs): + if self.animation_mode == AnimationModes.STATIC_STANDBY: + self.animation_mode = AnimationModes.STATIC + + self._enabled = not self._enabled + + def _key_led_inc(self, key, *args, **kwargs): + self.increase_brightness(leds=key.meta.leds) + + def _key_led_dec(self, key, *args, **kwargs): + self.decrease_brightness(leds=key.meta.leds) + + def _key_led_set(self, key, *args, **kwargs): + self.set_brightness(percent=key.meta.brightness, leds=key.meta.leds) + + def _key_led_ani(self, *args, **kwargs): + self.increase_ani() + + def _key_led_and(self, *args, **kwargs): + self.decrease_ani() + + def _key_led_mode_static(self, *args, **kwargs): + self._effect_init = True + self.animation_mode = AnimationModes.STATIC + + def _key_led_mode_breathe(self, *args, **kwargs): + self._effect_init = True + self.animation_mode = AnimationModes.BREATHING diff --git a/kmk/extensions/lock_status.py b/kmk/extensions/lock_status.py new file mode 100644 index 0000000..19d5128 --- /dev/null +++ b/kmk/extensions/lock_status.py @@ -0,0 +1,65 @@ +import usb_hid + +from kmk.extensions import Extension +from kmk.hid import HIDUsage + + +class LockCode: + NUMLOCK = 0x01 + CAPSLOCK = 0x02 + SCROLLLOCK = 0x04 + COMPOSE = 0x08 + KANA = 0x10 + RESERVED = 0x20 + + +class LockStatus(Extension): + def __init__(self): + self.report = 0x00 + self.hid = None + for device in usb_hid.devices: + if device.usage == HIDUsage.KEYBOARD: + self.hid = device + + def __repr__(self): + return ('LockStatus(report={})').format(self.report) + + def during_bootup(self, sandbox): + return + + def before_matrix_scan(self, sandbox): + return + + def after_matrix_scan(self, sandbox): + return + + def before_hid_send(self, sandbox): + return + + def after_hid_send(self, sandbox): + if self.hid: + report = self.hid.get_last_received_report() + if report[0] != self.report: + self.report = report[0] + return + + def on_powersave_enable(self, sandbox): + return + + def on_powersave_disable(self, sandbox): + return + + def get_num_lock(self): + return bool(self.report & LockCode.NUMLOCK) + + def get_caps_lock(self): + return bool(self.report & LockCode.CAPSLOCK) + + def get_scroll_lock(self): + return bool(self.report & LockCode.SCROLLLOCK) + + def get_compose(self): + return bool(self.report & LockCode.COMPOSE) + + def get_kana(self): + return bool(self.report & LockCode.KANA) diff --git a/kmk/extensions/media_keys.py b/kmk/extensions/media_keys.py new file mode 100644 index 0000000..84de64b --- /dev/null +++ b/kmk/extensions/media_keys.py @@ -0,0 +1,55 @@ +from kmk.extensions import Extension +from kmk.keys import make_consumer_key + + +class MediaKeys(Extension): + def __init__(self): + # Consumer ("media") keys. Most known keys aren't supported here. A much + # longer list used to exist in this file, but the codes were almost certainly + # incorrect, conflicting with each other, or otherwise 'weird'. We'll add them + # back in piecemeal as needed. PRs welcome. + # + # A super useful reference for these is http://www.freebsddiary.org/APC/usb_hid_usages.php + # Note that currently we only have the PC codes. Recent MacOS versions seem to + # support PC media keys, so I don't know how much value we would get out of + # adding the old Apple-specific consumer codes, but again, PRs welcome if the + # lack of them impacts you. + make_consumer_key(code=226, names=('AUDIO_MUTE', 'MUTE')) # 0xE2 + make_consumer_key(code=233, names=('AUDIO_VOL_UP', 'VOLU')) # 0xE9 + make_consumer_key(code=234, names=('AUDIO_VOL_DOWN', 'VOLD')) # 0xEA + make_consumer_key(code=181, names=('MEDIA_NEXT_TRACK', 'MNXT')) # 0xB5 + make_consumer_key(code=182, names=('MEDIA_PREV_TRACK', 'MPRV')) # 0xB6 + make_consumer_key(code=183, names=('MEDIA_STOP', 'MSTP')) # 0xB7 + make_consumer_key( + code=205, names=('MEDIA_PLAY_PAUSE', 'MPLY') + ) # 0xCD (this may not be right) + make_consumer_key(code=184, names=('MEDIA_EJECT', 'EJCT')) # 0xB8 + make_consumer_key(code=179, names=('MEDIA_FAST_FORWARD', 'MFFD')) # 0xB3 + make_consumer_key(code=180, names=('MEDIA_REWIND', 'MRWD')) # 0xB4 + + def on_runtime_enable(self, sandbox): + return + + def on_runtime_disable(self, sandbox): + return + + def during_bootup(self, sandbox): + return + + def before_matrix_scan(self, sandbox): + return + + def after_matrix_scan(self, sandbox): + return + + def before_hid_send(self, sandbox): + return + + def after_hid_send(self, sandbox): + return + + def on_powersave_enable(self, sandbox): + return + + def on_powersave_disable(self, sandbox): + return diff --git a/kmk/extensions/rgb.py b/kmk/extensions/rgb.py new file mode 100644 index 0000000..613e1fe --- /dev/null +++ b/kmk/extensions/rgb.py @@ -0,0 +1,582 @@ +from adafruit_pixelbuf import PixelBuf +from math import e, exp, pi, sin + +from kmk.extensions import Extension +from kmk.handlers.stock import passthrough as handler_passthrough +from kmk.keys import make_key +from kmk.kmktime import PeriodicTimer +from kmk.utils import clamp + +rgb_config = {} + + +def hsv_to_rgb(hue, sat, val): + ''' + Converts HSV values, and returns a tuple of RGB values + :param hue: + :param sat: + :param val: + :return: (r, g, b) + ''' + if sat == 0: + return (val, val, val) + + hue = 6 * (hue & 0xFF) + frac = hue & 0xFF + sxt = hue >> 8 + + base = (0xFF - sat) * val + color = (val * sat * frac) >> 8 + val <<= 8 + + if sxt == 0: + r = val + g = base + color + b = base + elif sxt == 1: + r = val - color + g = val + b = base + elif sxt == 2: + r = base + g = val + b = base + color + elif sxt == 3: + r = base + g = val - color + b = val + elif sxt == 4: + r = base + color + g = base + b = val + elif sxt == 5: + r = val + g = base + b = val - color + + return (r >> 8), (g >> 8), (b >> 8) + + +def hsv_to_rgbw(self, hue, sat, val): + ''' + Converts HSV values, and returns a tuple of RGBW values + :param hue: + :param sat: + :param val: + :return: (r, g, b, w) + ''' + rgb = hsv_to_rgb(hue, sat, val) + return rgb[0], rgb[1], rgb[2], min(rgb) + + +class AnimationModes: + OFF = 0 + STATIC = 1 + STATIC_STANDBY = 2 + BREATHING = 3 + RAINBOW = 4 + BREATHING_RAINBOW = 5 + KNIGHT = 6 + SWIRL = 7 + USER = 8 + + +class RGB(Extension): + pos = 0 + + def __init__( + self, + pixel_pin, + num_pixels=0, + val_limit=255, + hue_default=0, + sat_default=255, + rgb_order=(1, 0, 2), # GRB WS2812 + val_default=255, + hue_step=4, + sat_step=13, + val_step=13, + animation_speed=1, + breathe_center=1, # 1.0-2.7 + knight_effect_length=3, + animation_mode=AnimationModes.STATIC, + effect_init=False, + reverse_animation=False, + user_animation=None, + disable_auto_write=False, + pixels=None, + refresh_rate=60, + ): + if pixels is None: + import neopixel + + pixels = neopixel.NeoPixel( + pixel_pin, + num_pixels, + pixel_order=rgb_order, + auto_write=not disable_auto_write, + ) + self.pixels = pixels + self.num_pixels = num_pixels + + # PixelBuffer are already iterable, can't do the usual `try: iter(...)` + if issubclass(self.pixels.__class__, PixelBuf): + self.pixels = (self.pixels,) + + if self.num_pixels == 0: + for pixels in self.pixels: + self.num_pixels += len(pixels) + + self.rgbw = bool(len(rgb_order) == 4) + + self.hue_step = hue_step + self.sat_step = sat_step + self.val_step = val_step + self.hue = hue_default + self.hue_default = hue_default + self.sat = sat_default + self.sat_default = sat_default + self.val = val_default + self.val_default = val_default + self.breathe_center = breathe_center + self.knight_effect_length = knight_effect_length + self.val_limit = val_limit + self.animation_mode = animation_mode + self.animation_speed = animation_speed + self.effect_init = effect_init + self.reverse_animation = reverse_animation + self.user_animation = user_animation + self.disable_auto_write = disable_auto_write + self.refresh_rate = refresh_rate + + self._substep = 0 + + make_key( + names=('RGB_TOG',), on_press=self._rgb_tog, on_release=handler_passthrough + ) + make_key( + names=('RGB_HUI',), on_press=self._rgb_hui, on_release=handler_passthrough + ) + make_key( + names=('RGB_HUD',), on_press=self._rgb_hud, on_release=handler_passthrough + ) + make_key( + names=('RGB_SAI',), on_press=self._rgb_sai, on_release=handler_passthrough + ) + make_key( + names=('RGB_SAD',), on_press=self._rgb_sad, on_release=handler_passthrough + ) + make_key( + names=('RGB_VAI',), on_press=self._rgb_vai, on_release=handler_passthrough + ) + make_key( + names=('RGB_VAD',), on_press=self._rgb_vad, on_release=handler_passthrough + ) + make_key( + names=('RGB_ANI',), on_press=self._rgb_ani, on_release=handler_passthrough + ) + make_key( + names=('RGB_AND',), on_press=self._rgb_and, on_release=handler_passthrough + ) + make_key( + names=('RGB_MODE_PLAIN', 'RGB_M_P'), + on_press=self._rgb_mode_static, + on_release=handler_passthrough, + ) + make_key( + names=('RGB_MODE_BREATHE', 'RGB_M_B'), + on_press=self._rgb_mode_breathe, + on_release=handler_passthrough, + ) + make_key( + names=('RGB_MODE_RAINBOW', 'RGB_M_R'), + on_press=self._rgb_mode_rainbow, + on_release=handler_passthrough, + ) + make_key( + names=('RGB_MODE_BREATHE_RAINBOW', 'RGB_M_BR'), + on_press=self._rgb_mode_breathe_rainbow, + on_release=handler_passthrough, + ) + make_key( + names=('RGB_MODE_SWIRL', 'RGB_M_S'), + on_press=self._rgb_mode_swirl, + on_release=handler_passthrough, + ) + make_key( + names=('RGB_MODE_KNIGHT', 'RGB_M_K'), + on_press=self._rgb_mode_knight, + on_release=handler_passthrough, + ) + make_key( + names=('RGB_RESET', 'RGB_RST'), + on_press=self._rgb_reset, + on_release=handler_passthrough, + ) + + def on_runtime_enable(self, sandbox): + return + + def on_runtime_disable(self, sandbox): + return + + def during_bootup(self, sandbox): + self._timer = PeriodicTimer(1000 // self.refresh_rate) + + def before_matrix_scan(self, sandbox): + return + + def after_matrix_scan(self, sandbox): + return + + def before_hid_send(self, sandbox): + return + + def after_hid_send(self, sandbox): + self.animate() + + def on_powersave_enable(self, sandbox): + return + + def on_powersave_disable(self, sandbox): + self._do_update() + + def set_hsv(self, hue, sat, val, index): + ''' + Takes HSV values and displays it on a single LED/Neopixel + :param hue: + :param sat: + :param val: + :param index: Index of LED/Pixel + ''' + if self.rgbw: + self.set_rgb(hsv_to_rgbw(hue, sat, val), index) + else: + self.set_rgb(hsv_to_rgb(hue, sat, val), index) + + def set_hsv_fill(self, hue, sat, val): + ''' + Takes HSV values and displays it on all LEDs/Neopixels + :param hue: + :param sat: + :param val: + ''' + if self.rgbw: + self.set_rgb_fill(hsv_to_rgbw(hue, sat, val)) + else: + self.set_rgb_fill(hsv_to_rgb(hue, sat, val)) + + def set_rgb(self, rgb, index): + ''' + Takes an RGB or RGBW and displays it on a single LED/Neopixel + :param rgb: RGB or RGBW + :param index: Index of LED/Pixel + ''' + if 0 <= index <= self.num_pixels - 1: + for pixels in self.pixels: + if index <= (len(pixels) - 1): + break + index -= len(pixels) + + pixels[index] = rgb + + if not self.disable_auto_write: + pixels.show() + + def set_rgb_fill(self, rgb): + ''' + Takes an RGB or RGBW and displays it on all LEDs/Neopixels + :param rgb: RGB or RGBW + ''' + for pixels in self.pixels: + pixels.fill(rgb) + if not self.disable_auto_write: + pixels.show() + + def increase_hue(self, step=None): + ''' + Increases hue by step amount rolling at 256 and returning to 0 + :param step: + ''' + if step is None: + step = self.hue_step + + self.hue = (self.hue + step) % 256 + + if self._check_update(): + self._do_update() + + def decrease_hue(self, step=None): + ''' + Decreases hue by step amount rolling at 0 and returning to 256 + :param step: + ''' + if step is None: + step = self.hue_step + + if (self.hue - step) <= 0: + self.hue = (self.hue + 256 - step) % 256 + else: + self.hue = (self.hue - step) % 256 + + if self._check_update(): + self._do_update() + + def increase_sat(self, step=None): + ''' + Increases saturation by step amount stopping at 255 + :param step: + ''' + if step is None: + step = self.sat_step + + self.sat = clamp(self.sat + step, 0, 255) + + if self._check_update(): + self._do_update() + + def decrease_sat(self, step=None): + ''' + Decreases saturation by step amount stopping at 0 + :param step: + ''' + if step is None: + step = self.sat_step + + self.sat = clamp(self.sat - step, 0, 255) + + if self._check_update(): + self._do_update() + + def increase_val(self, step=None): + ''' + Increases value by step amount stopping at 100 + :param step: + ''' + if step is None: + step = self.val_step + + self.val = clamp(self.val + step, 0, 255) + + if self._check_update(): + self._do_update() + + def decrease_val(self, step=None): + ''' + Decreases value by step amount stopping at 0 + :param step: + ''' + if step is None: + step = self.val_step + + self.val = clamp(self.val - step, 0, 255) + + if self._check_update(): + self._do_update() + + def increase_ani(self): + ''' + Increases animation speed by 1 amount stopping at 10 + :param step: + ''' + self.animation_speed = clamp(self.animation_speed + 1, 0, 10) + + if self._check_update(): + self._do_update() + + def decrease_ani(self): + ''' + Decreases animation speed by 1 amount stopping at 0 + :param step: + ''' + self.animation_speed = clamp(self.animation_speed - 1, 0, 10) + + if self._check_update(): + self._do_update() + + def off(self): + ''' + Turns off all LEDs/Neopixels without changing stored values + ''' + self.set_hsv_fill(0, 0, 0) + + def show(self): + ''' + Turns on all LEDs/Neopixels without changing stored values + ''' + for pixels in self.pixels: + pixels.show() + + def animate(self): + ''' + Activates a "step" in the animation based on the active mode + :return: Returns the new state in animation + ''' + if self.effect_init: + self._init_effect() + + if self.animation_mode is AnimationModes.STATIC_STANDBY: + return + + if self.enable and self._timer.tick(): + self._animation_step() + if self.animation_mode == AnimationModes.BREATHING: + self.effect_breathing() + elif self.animation_mode == AnimationModes.RAINBOW: + self.effect_rainbow() + elif self.animation_mode == AnimationModes.BREATHING_RAINBOW: + self.effect_breathing_rainbow() + elif self.animation_mode == AnimationModes.STATIC: + self.effect_static() + elif self.animation_mode == AnimationModes.KNIGHT: + self.effect_knight() + elif self.animation_mode == AnimationModes.SWIRL: + self.effect_swirl() + elif self.animation_mode == AnimationModes.USER: + self.user_animation(self) + elif self.animation_mode == AnimationModes.STATIC_STANDBY: + pass + else: + self.off() + + def _animation_step(self): + self._substep += self.animation_speed / 4 + self._step = int(self._substep) + self._substep -= self._step + + def _init_effect(self): + self.pos = 0 + self.reverse_animation = False + self.effect_init = False + + def _check_update(self): + return bool(self.animation_mode == AnimationModes.STATIC_STANDBY) + + def _do_update(self): + if self.animation_mode == AnimationModes.STATIC_STANDBY: + self.animation_mode = AnimationModes.STATIC + + def effect_static(self): + self.set_hsv_fill(self.hue, self.sat, self.val) + self.animation_mode = AnimationModes.STATIC_STANDBY + + def effect_breathing(self): + # http://sean.voisen.org/blog/2011/10/breathing-led-with-arduino/ + # https://github.com/qmk/qmk_firmware/blob/9f1d781fcb7129a07e671a46461e501e3f1ae59d/quantum/rgblight.c#L806 + sined = sin((self.pos / 255.0) * pi) + multip_1 = exp(sined) - self.breathe_center / e + multip_2 = self.val_limit / (e - 1 / e) + + self.val = int(multip_1 * multip_2) + self.pos = (self.pos + self._step) % 256 + self.set_hsv_fill(self.hue, self.sat, self.val) + + def effect_breathing_rainbow(self): + self.increase_hue(self._step) + self.effect_breathing() + + def effect_rainbow(self): + self.increase_hue(self._step) + self.set_hsv_fill(self.hue, self.sat, self.val) + + def effect_swirl(self): + self.increase_hue(self._step) + self.disable_auto_write = True # Turn off instantly showing + for i in range(0, self.num_pixels): + self.set_hsv( + (self.hue - (i * self.num_pixels)) % 256, self.sat, self.val, i + ) + + # Show final results + self.disable_auto_write = False # Resume showing changes + self.show() + + def effect_knight(self): + # Determine which LEDs should be lit up + self.disable_auto_write = True # Turn off instantly showing + self.off() # Fill all off + pos = int(self.pos) + + # Set all pixels on in range of animation length offset by position + for i in range(pos, (pos + self.knight_effect_length)): + self.set_hsv(self.hue, self.sat, self.val, i) + + # Reverse animation when a boundary is hit + if pos >= self.num_pixels or pos - 1 < (self.knight_effect_length * -1): + self.reverse_animation = not self.reverse_animation + + if self.reverse_animation: + self.pos -= self._step / 2 + else: + self.pos += self._step / 2 + + # Show final results + self.disable_auto_write = False # Resume showing changes + self.show() + + def _rgb_tog(self, *args, **kwargs): + if self.animation_mode == AnimationModes.STATIC: + self.animation_mode = AnimationModes.STATIC_STANDBY + self._do_update() + if self.animation_mode == AnimationModes.STATIC_STANDBY: + self.animation_mode = AnimationModes.STATIC + self._do_update() + if self.enable: + self.off() + self.enable = not self.enable + + def _rgb_hui(self, *args, **kwargs): + self.increase_hue() + + def _rgb_hud(self, *args, **kwargs): + self.decrease_hue() + + def _rgb_sai(self, *args, **kwargs): + self.increase_sat() + + def _rgb_sad(self, *args, **kwargs): + self.decrease_sat() + + def _rgb_vai(self, *args, **kwargs): + self.increase_val() + + def _rgb_vad(self, *args, **kwargs): + self.decrease_val() + + def _rgb_ani(self, *args, **kwargs): + self.increase_ani() + + def _rgb_and(self, *args, **kwargs): + self.decrease_ani() + + def _rgb_mode_static(self, *args, **kwargs): + self.effect_init = True + self.animation_mode = AnimationModes.STATIC + + def _rgb_mode_breathe(self, *args, **kwargs): + self.effect_init = True + self.animation_mode = AnimationModes.BREATHING + + def _rgb_mode_breathe_rainbow(self, *args, **kwargs): + self.effect_init = True + self.animation_mode = AnimationModes.BREATHING_RAINBOW + + def _rgb_mode_rainbow(self, *args, **kwargs): + self.effect_init = True + self.animation_mode = AnimationModes.RAINBOW + + def _rgb_mode_swirl(self, *args, **kwargs): + self.effect_init = True + self.animation_mode = AnimationModes.SWIRL + + def _rgb_mode_knight(self, *args, **kwargs): + self.effect_init = True + self.animation_mode = AnimationModes.KNIGHT + + def _rgb_reset(self, *args, **kwargs): + self.hue = self.hue_default + self.sat = self.sat_default + self.val = self.val_default + if self.animation_mode == AnimationModes.STATIC_STANDBY: + self.animation_mode = AnimationModes.STATIC + self._do_update() diff --git a/kmk/extensions/statusled.py b/kmk/extensions/statusled.py new file mode 100644 index 0000000..66413b6 --- /dev/null +++ b/kmk/extensions/statusled.py @@ -0,0 +1,145 @@ +# Use this extension for showing layer status with three leds + +import pwmio +import time + +from kmk.extensions import Extension, InvalidExtensionEnvironment +from kmk.keys import make_key + + +class statusLED(Extension): + def __init__( + self, + led_pins, + brightness=30, + brightness_step=5, + brightness_limit=100, + ): + self._leds = [] + for led in led_pins: + try: + self._leds.append(pwmio.PWMOut(led)) + except Exception as e: + print(e) + raise InvalidExtensionEnvironment( + 'Unable to create pulseio.PWMOut() instance with provided led_pin' + ) + self._led_count = len(self._leds) + + self.brightness = brightness + self._layer_last = -1 + + self.brightness_step = brightness_step + self.brightness_limit = brightness_limit + + make_key(names=('SLED_INC',), on_press=self._key_led_inc) + make_key(names=('SLED_DEC',), on_press=self._key_led_dec) + + def _layer_indicator(self, layer_active, *args, **kwargs): + ''' + Indicates layer with leds + + For the time being just a simple consecutive single led + indicator. And when there are more layers than leds it + wraps around to the first led again. + (Also works for a single led, which just lights when any + layer is active) + ''' + + if self._layer_last != layer_active: + led_last = 0 if self._layer_last == 0 else 1 + (self._layer_last - 1) % 3 + if layer_active > 0: + led_active = 0 if layer_active == 0 else 1 + (layer_active - 1) % 3 + self.set_brightness(self.brightness, led_active) + self.set_brightness(0, led_last) + else: + self.set_brightness(0, led_last) + self._layer_last = layer_active + + def __repr__(self): + return 'SLED({})'.format(self._to_dict()) + + def _to_dict(self): + return { + '_brightness': self.brightness, + 'brightness_step': self.brightness_step, + 'brightness_limit': self.brightness_limit, + } + + def on_runtime_enable(self, sandbox): + return + + def on_runtime_disable(self, sandbox): + return + + def during_bootup(self, sandbox): + '''Light up every single led once for 200 ms''' + for i in range(self._led_count + 2): + if i < self._led_count: + self._leds[i].duty_cycle = int(self.brightness / 100 * 65535) + i_off = i - 2 + if i_off >= 0 and i_off < self._led_count: + self._leds[i_off].duty_cycle = int(0) + time.sleep(0.1) + for led in self._leds: + led.duty_cycle = int(0) + return + + def before_matrix_scan(self, sandbox): + return + + def after_matrix_scan(self, sandbox): + self._layer_indicator(sandbox.active_layers[0]) + return + + def before_hid_send(self, sandbox): + return + + def after_hid_send(self, sandbox): + return + + def on_powersave_enable(self, sandbox): + self.set_brightness(0) + return + + def on_powersave_disable(self, sandbox): + self.set_brightness(self._brightness) + self._leds[2].duty_cycle = int(50 / 100 * 65535) + time.sleep(0.2) + self._leds[2].duty_cycle = int(0) + return + + def set_brightness(self, percent, layer_id=-1): + if layer_id < 0: + for led in self._leds: + led.duty_cycle = int(percent / 100 * 65535) + else: + self._leds[layer_id - 1].duty_cycle = int(percent / 100 * 65535) + + def increase_brightness(self, step=None): + if not step: + self._brightness += self.brightness_step + else: + self._brightness += step + + if self._brightness > 100: + self._brightness = 100 + + self.set_brightness(self._brightness, self._layer_last) + + def decrease_brightness(self, step=None): + if not step: + self._brightness -= self.brightness_step + else: + self._brightness -= step + + if self._brightness < 0: + self._brightness = 0 + + self.set_brightness(self._brightness, self._layer_last) + + def _key_led_inc(self, *args, **kwargs): + self.increase_brightness() + + def _key_led_dec(self, *args, **kwargs): + self.decrease_brightness() diff --git a/kmk/handlers/__init__.py b/kmk/handlers/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/kmk/handlers/sequences.py b/kmk/handlers/sequences.py new file mode 100644 index 0000000..936c17e --- /dev/null +++ b/kmk/handlers/sequences.py @@ -0,0 +1,155 @@ +import gc + +from kmk.consts import UnicodeMode +from kmk.handlers.stock import passthrough +from kmk.keys import KC, make_key +from kmk.types import AttrDict, KeySequenceMeta + + +def get_wide_ordinal(char): + if len(char) != 2: + return ord(char) + + return 0x10000 + (ord(char[0]) - 0xD800) * 0x400 + (ord(char[1]) - 0xDC00) + + +def sequence_press_handler(key, keyboard, KC, *args, **kwargs): + oldkeys_pressed = keyboard.keys_pressed + keyboard.keys_pressed = set() + + for ikey in key.meta.seq: + if not getattr(ikey, 'no_press', None): + keyboard.process_key(ikey, True) + keyboard._send_hid() + if not getattr(ikey, 'no_release', None): + keyboard.process_key(ikey, False) + keyboard._send_hid() + + keyboard.keys_pressed = oldkeys_pressed + + return keyboard + + +def simple_key_sequence(seq): + return make_key( + meta=KeySequenceMeta(seq), + on_press=sequence_press_handler, + on_release=passthrough, + ) + + +def send_string(message): + seq = [] + + for char in message: + kc = getattr(KC, char.upper()) + + if char.isupper(): + kc = KC.LSHIFT(kc) + + seq.append(kc) + + return simple_key_sequence(seq) + + +IBUS_KEY_COMBO = simple_key_sequence((KC.LCTRL(KC.LSHIFT(KC.U)),)) +RALT_KEY = simple_key_sequence((KC.RALT,)) +U_KEY = simple_key_sequence((KC.U,)) +ENTER_KEY = simple_key_sequence((KC.ENTER,)) +RALT_DOWN_NO_RELEASE = simple_key_sequence((KC.RALT(no_release=True),)) +RALT_UP_NO_PRESS = simple_key_sequence((KC.RALT(no_press=True),)) + + +def compile_unicode_string_sequences(string_table): + ''' + Destructively convert ("compile") unicode strings into key sequences. This + will, for RAM saving reasons, empty the input dictionary and trigger + garbage collection. + ''' + target = AttrDict() + + for k, v in string_table.items(): + target[k] = unicode_string_sequence(v) + + # now loop through and kill the input dictionary to save RAM + for k in target.keys(): + del string_table[k] + + gc.collect() + + return target + + +def unicode_string_sequence(unistring): + ''' + Allows sending things like (╯°□°)╯︵ ┻━┻ directly, without + manual conversion to Unicode codepoints. + ''' + return unicode_codepoint_sequence([hex(get_wide_ordinal(s))[2:] for s in unistring]) + + +def generate_codepoint_keysym_seq(codepoint, expected_length=4): + # To make MacOS and Windows happy, always try to send + # sequences that are of length 4 at a minimum + # On Linux systems, we can happily send longer strings. + # They will almost certainly break on MacOS and Windows, + # but this is a documentation problem more than anything. + # Not sure how to send emojis on Mac/Windows like that, + # though, since (for example) the Canadian flag is assembled + # from two five-character codepoints, 1f1e8 and 1f1e6 + seq = [KC.N0 for _ in range(max(len(codepoint), expected_length))] + + for idx, codepoint_fragment in enumerate(reversed(codepoint)): + seq[-(idx + 1)] = KC.__getattr__(codepoint_fragment.upper()) + + return seq + + +def unicode_codepoint_sequence(codepoints): + kc_seqs = (generate_codepoint_keysym_seq(codepoint) for codepoint in codepoints) + + kc_macros = [simple_key_sequence(kc_seq) for kc_seq in kc_seqs] + + def _unicode_sequence(key, keyboard, *args, **kwargs): + if keyboard.unicode_mode == UnicodeMode.IBUS: + keyboard.process_key( + simple_key_sequence(_ibus_unicode_sequence(kc_macros, keyboard)), True + ) + elif keyboard.unicode_mode == UnicodeMode.RALT: + keyboard.process_key( + simple_key_sequence(_ralt_unicode_sequence(kc_macros, keyboard)), True + ) + elif keyboard.unicode_mode == UnicodeMode.WINC: + keyboard.process_key( + simple_key_sequence(_winc_unicode_sequence(kc_macros, keyboard)), True + ) + + return make_key(on_press=_unicode_sequence) + + +def _ralt_unicode_sequence(kc_macros, keyboard): + for kc_macro in kc_macros: + yield RALT_DOWN_NO_RELEASE + yield kc_macro + yield RALT_UP_NO_PRESS + + +def _ibus_unicode_sequence(kc_macros, keyboard): + for kc_macro in kc_macros: + yield IBUS_KEY_COMBO + yield kc_macro + yield ENTER_KEY + + +def _winc_unicode_sequence(kc_macros, keyboard): + ''' + Send unicode sequence using WinCompose: + + http://wincompose.info/ + https://github.com/SamHocevar/wincompose + ''' + for kc_macro in kc_macros: + yield RALT_KEY + yield U_KEY + yield kc_macro + yield ENTER_KEY diff --git a/kmk/handlers/stock.py b/kmk/handlers/stock.py new file mode 100644 index 0000000..7a2d007 --- /dev/null +++ b/kmk/handlers/stock.py @@ -0,0 +1,125 @@ +from time import sleep + + +def passthrough(key, keyboard, *args, **kwargs): + return keyboard + + +def default_pressed(key, keyboard, KC, coord_int=None, coord_raw=None, *args, **kwargs): + keyboard.hid_pending = True + + keyboard.keys_pressed.add(key) + + return keyboard + + +def default_released( + key, keyboard, KC, coord_int=None, coord_raw=None, *args, **kwargs # NOQA +): + keyboard.hid_pending = True + keyboard.keys_pressed.discard(key) + + return keyboard + + +def reset(*args, **kwargs): + import microcontroller + + microcontroller.reset() + + +def bootloader(*args, **kwargs): + import microcontroller + + microcontroller.on_next_reset(microcontroller.RunMode.BOOTLOADER) + microcontroller.reset() + + +def debug_pressed(key, keyboard, KC, *args, **kwargs): + if keyboard.debug_enabled: + print('DebugDisable()') + else: + print('DebugEnable()') + + keyboard.debug_enabled = not keyboard.debug_enabled + + return keyboard + + +def gesc_pressed(key, keyboard, KC, *args, **kwargs): + GESC_TRIGGERS = {KC.LSHIFT, KC.RSHIFT, KC.LGUI, KC.RGUI} + + if GESC_TRIGGERS.intersection(keyboard.keys_pressed): + # First, release GUI if already pressed + keyboard._send_hid() + # if Shift is held, KC_GRAVE will become KC_TILDE on OS level + keyboard.keys_pressed.add(KC.GRAVE) + keyboard.hid_pending = True + return keyboard + + # else return KC_ESC + keyboard.keys_pressed.add(KC.ESCAPE) + keyboard.hid_pending = True + + return keyboard + + +def gesc_released(key, keyboard, KC, *args, **kwargs): + keyboard.keys_pressed.discard(KC.ESCAPE) + keyboard.keys_pressed.discard(KC.GRAVE) + keyboard.hid_pending = True + return keyboard + + +def bkdl_pressed(key, keyboard, KC, *args, **kwargs): + BKDL_TRIGGERS = {KC.LGUI, KC.RGUI} + + if BKDL_TRIGGERS.intersection(keyboard.keys_pressed): + keyboard._send_hid() + keyboard.keys_pressed.add(KC.DEL) + keyboard.hid_pending = True + return keyboard + + # else return KC_ESC + keyboard.keys_pressed.add(KC.BKSP) + keyboard.hid_pending = True + + return keyboard + + +def bkdl_released(key, keyboard, KC, *args, **kwargs): + keyboard.keys_pressed.discard(KC.BKSP) + keyboard.keys_pressed.discard(KC.DEL) + keyboard.hid_pending = True + return keyboard + + +def sleep_pressed(key, keyboard, KC, *args, **kwargs): + sleep(key.meta.ms / 1000) + return keyboard + + +def uc_mode_pressed(key, keyboard, *args, **kwargs): + keyboard.unicode_mode = key.meta.mode + + return keyboard + + +def hid_switch(key, keyboard, *args, **kwargs): + keyboard.hid_type, keyboard.secondary_hid_type = ( + keyboard.secondary_hid_type, + keyboard.hid_type, + ) + keyboard._init_hid() + return keyboard + + +def ble_refresh(key, keyboard, *args, **kwargs): + from kmk.hid import HIDModes + + if keyboard.hid_type != HIDModes.BLE: + return keyboard + + keyboard._hid_helper.stop_advertising() + keyboard._hid_helper.start_advertising() + return keyboard diff --git a/kmk/hid.py b/kmk/hid.py new file mode 100644 index 0000000..5f099a4 --- /dev/null +++ b/kmk/hid.py @@ -0,0 +1,324 @@ +import usb_hid +from micropython import const + +from storage import getmount + +from kmk.keys import FIRST_KMK_INTERNAL_KEY, ConsumerKey, ModifierKey + +try: + from adafruit_ble import BLERadio + from adafruit_ble.advertising.standard import ProvideServicesAdvertisement + from adafruit_ble.services.standard.hid import HIDService +except ImportError: + # BLE not supported on this platform + pass + + +class HIDModes: + NOOP = 0 # currently unused; for testing? + USB = 1 + BLE = 2 + + ALL_MODES = (NOOP, USB, BLE) + + +class HIDReportTypes: + KEYBOARD = 1 + MOUSE = 2 + CONSUMER = 3 + SYSCONTROL = 4 + + +class HIDUsage: + KEYBOARD = 0x06 + MOUSE = 0x02 + CONSUMER = 0x01 + SYSCONTROL = 0x80 + + +class HIDUsagePage: + CONSUMER = 0x0C + KEYBOARD = MOUSE = SYSCONTROL = 0x01 + + +HID_REPORT_SIZES = { + HIDReportTypes.KEYBOARD: 8, + HIDReportTypes.MOUSE: 4, + HIDReportTypes.CONSUMER: 2, + HIDReportTypes.SYSCONTROL: 8, # TODO find the correct value for this +} + + +class AbstractHID: + REPORT_BYTES = 8 + + def __init__(self, **kwargs): + self._prev_evt = bytearray(self.REPORT_BYTES) + self._evt = bytearray(self.REPORT_BYTES) + self.report_device = memoryview(self._evt)[0:1] + self.report_device[0] = HIDReportTypes.KEYBOARD + + # Landmine alert for HIDReportTypes.KEYBOARD: byte index 1 of this view + # is "reserved" and evidently (mostly?) unused. However, other modes (or + # at least consumer, so far) will use this byte, which is the main reason + # this view exists. For KEYBOARD, use report_mods and report_non_mods + self.report_keys = memoryview(self._evt)[1:] + + self.report_mods = memoryview(self._evt)[1:2] + self.report_non_mods = memoryview(self._evt)[3:] + + self.post_init() + + def __repr__(self): + return '{}(REPORT_BYTES={})'.format(self.__class__.__name__, self.REPORT_BYTES) + + def post_init(self): + pass + + def create_report(self, keys_pressed): + self.clear_all() + + consumer_key = None + for key in keys_pressed: + if isinstance(key, ConsumerKey): + consumer_key = key + break + + reporting_device = self.report_device[0] + needed_reporting_device = HIDReportTypes.KEYBOARD + + if consumer_key: + needed_reporting_device = HIDReportTypes.CONSUMER + + if reporting_device != needed_reporting_device: + # If we are about to change reporting devices, release + # all keys and close our proverbial tab on the existing + # device, or keys will get stuck (mostly when releasing + # media/consumer keys) + self.send() + + self.report_device[0] = needed_reporting_device + + if consumer_key: + self.add_key(consumer_key) + else: + for key in keys_pressed: + if key.code >= FIRST_KMK_INTERNAL_KEY: + continue + + if isinstance(key, ModifierKey): + self.add_modifier(key) + else: + self.add_key(key) + + if key.has_modifiers: + for mod in key.has_modifiers: + self.add_modifier(mod) + + return self + + def hid_send(self, evt): + # Don't raise a NotImplementedError so this can serve as our "dummy" HID + # when MCU/board doesn't define one to use (which should almost always be + # the CircuitPython-targeting one, except when unit testing or doing + # something truly bizarre. This will likely change eventually when Bluetooth + # is added) + pass + + def send(self): + if self._evt != self._prev_evt: + self._prev_evt[:] = self._evt + self.hid_send(self._evt) + + return self + + def clear_all(self): + for idx, _ in enumerate(self.report_keys): + self.report_keys[idx] = 0x00 + + return self + + def clear_non_modifiers(self): + for idx, _ in enumerate(self.report_non_mods): + self.report_non_mods[idx] = 0x00 + + return self + + def add_modifier(self, modifier): + if isinstance(modifier, ModifierKey): + if modifier.code == ModifierKey.FAKE_CODE: + for mod in modifier.has_modifiers: + self.report_mods[0] |= mod + else: + self.report_mods[0] |= modifier.code + else: + self.report_mods[0] |= modifier + + return self + + def remove_modifier(self, modifier): + if isinstance(modifier, ModifierKey): + if modifier.code == ModifierKey.FAKE_CODE: + for mod in modifier.has_modifiers: + self.report_mods[0] ^= mod + else: + self.report_mods[0] ^= modifier.code + else: + self.report_mods[0] ^= modifier + + return self + + def add_key(self, key): + # Try to find the first empty slot in the key report, and fill it + placed = False + + where_to_place = self.report_non_mods + + if self.report_device[0] == HIDReportTypes.CONSUMER: + where_to_place = self.report_keys + + for idx, _ in enumerate(where_to_place): + if where_to_place[idx] == 0x00: + where_to_place[idx] = key.code + placed = True + break + + if not placed: + # TODO what do we do here?...... + pass + + return self + + def remove_key(self, key): + where_to_place = self.report_non_mods + + if self.report_device[0] == HIDReportTypes.CONSUMER: + where_to_place = self.report_keys + + for idx, _ in enumerate(where_to_place): + if where_to_place[idx] == key.code: + where_to_place[idx] = 0x00 + + return self + + +class USBHID(AbstractHID): + REPORT_BYTES = 9 + + def post_init(self): + self.devices = {} + + for device in usb_hid.devices: + us = device.usage + up = device.usage_page + + if up == HIDUsagePage.CONSUMER and us == HIDUsage.CONSUMER: + self.devices[HIDReportTypes.CONSUMER] = device + continue + + if up == HIDUsagePage.KEYBOARD and us == HIDUsage.KEYBOARD: + self.devices[HIDReportTypes.KEYBOARD] = device + continue + + if up == HIDUsagePage.MOUSE and us == HIDUsage.MOUSE: + self.devices[HIDReportTypes.MOUSE] = device + continue + + if up == HIDUsagePage.SYSCONTROL and us == HIDUsage.SYSCONTROL: + self.devices[HIDReportTypes.SYSCONTROL] = device + continue + + def hid_send(self, evt): + # int, can be looked up in HIDReportTypes + reporting_device_const = evt[0] + + return self.devices[reporting_device_const].send_report( + evt[1 : HID_REPORT_SIZES[reporting_device_const] + 1] + ) + + +class BLEHID(AbstractHID): + BLE_APPEARANCE_HID_KEYBOARD = const(961) + # Hardcoded in CPy + MAX_CONNECTIONS = const(2) + + def __init__(self, ble_name=str(getmount('/').label), **kwargs): + self.ble_name = ble_name + super().__init__() + + def post_init(self): + self.ble = BLERadio() + self.ble.name = self.ble_name + self.hid = HIDService() + self.hid.protocol_mode = 0 # Boot protocol + + # Security-wise this is not right. While you're away someone turns + # on your keyboard and they can pair with it nice and clean and then + # listen to keystrokes. + # On the other hand we don't have LESC so it's like shouting your + # keystrokes in the air + if not self.ble.connected or not self.hid.devices: + self.start_advertising() + + @property + def devices(self): + '''Search through the provided list of devices to find the ones with the + send_report attribute.''' + if not self.ble.connected: + return {} + + result = {} + + for device in self.hid.devices: + if not hasattr(device, 'send_report'): + continue + us = device.usage + up = device.usage_page + + if up == HIDUsagePage.CONSUMER and us == HIDUsage.CONSUMER: + result[HIDReportTypes.CONSUMER] = device + continue + + if up == HIDUsagePage.KEYBOARD and us == HIDUsage.KEYBOARD: + result[HIDReportTypes.KEYBOARD] = device + continue + + if up == HIDUsagePage.MOUSE and us == HIDUsage.MOUSE: + result[HIDReportTypes.MOUSE] = device + continue + + if up == HIDUsagePage.SYSCONTROL and us == HIDUsage.SYSCONTROL: + result[HIDReportTypes.SYSCONTROL] = device + continue + + return result + + def hid_send(self, evt): + if not self.ble.connected: + return + + # int, can be looked up in HIDReportTypes + reporting_device_const = evt[0] + + device = self.devices[reporting_device_const] + + report_size = len(device._characteristic.value) + while len(evt) < report_size + 1: + evt.append(0) + + return device.send_report(evt[1 : report_size + 1]) + + def clear_bonds(self): + import _bleio + + _bleio.adapter.erase_bonding() + + def start_advertising(self): + if not self.ble.advertising: + advertisement = ProvideServicesAdvertisement(self.hid) + advertisement.appearance = self.BLE_APPEARANCE_HID_KEYBOARD + + self.ble.start_advertising(advertisement) + + def stop_advertising(self): + self.ble.stop_advertising() diff --git a/kmk/key_validators.py b/kmk/key_validators.py new file mode 100644 index 0000000..8a4ba25 --- /dev/null +++ b/kmk/key_validators.py @@ -0,0 +1,53 @@ +from kmk.types import ( + KeySeqSleepMeta, + LayerKeyMeta, + ModTapKeyMeta, + TapDanceKeyMeta, + UnicodeModeKeyMeta, +) + + +def key_seq_sleep_validator(ms): + return KeySeqSleepMeta(ms) + + +def layer_key_validator( + layer, kc=None, prefer_hold=False, tap_interrupted=False, tap_time=None +): + ''' + Validates the syntax (but not semantics) of a layer key call. We won't + have access to the keymap here, so we can't verify much of anything useful + here (like whether the target layer actually exists). The spirit of this + existing is mostly that Python will catch extraneous args/kwargs and error + out. + ''' + return LayerKeyMeta( + layer=layer, + kc=kc, + prefer_hold=prefer_hold, + tap_interrupted=tap_interrupted, + tap_time=tap_time, + ) + + +def mod_tap_validator( + kc, mods=None, prefer_hold=True, tap_interrupted=False, tap_time=None +): + ''' + Validates that mod tap keys are correctly used + ''' + return ModTapKeyMeta( + kc=kc, + mods=mods, + prefer_hold=prefer_hold, + tap_interrupted=tap_interrupted, + tap_time=tap_time, + ) + + +def tap_dance_key_validator(*codes): + return TapDanceKeyMeta(codes) + + +def unicode_mode_key_validator(mode): + return UnicodeModeKeyMeta(mode) diff --git a/kmk/keys.py b/kmk/keys.py new file mode 100644 index 0000000..9465918 --- /dev/null +++ b/kmk/keys.py @@ -0,0 +1,721 @@ +import gc +from micropython import const + +import kmk.handlers.stock as handlers +from kmk.consts import UnicodeMode +from kmk.key_validators import key_seq_sleep_validator, unicode_mode_key_validator +from kmk.types import UnicodeModeKeyMeta + +DEBUG_OUTPUT = False + +FIRST_KMK_INTERNAL_KEY = const(1000) +NEXT_AVAILABLE_KEY = 1000 + +KEY_SIMPLE = const(0) +KEY_MODIFIER = const(1) +KEY_CONSUMER = const(2) + +ALL_ALPHAS = 'ABCDEFGHIJKLMNOPQRSTUVWXYZ' +ALL_NUMBERS = '1234567890' +# since KC.1 isn't valid Python, alias to KC.N1 +ALL_NUMBER_ALIASES = tuple(f'N{x}' for x in ALL_NUMBERS) + + +# this is a bit of an FP style thing - combining a pipe operator a-la F# with +# a bootleg Maybe monad to clean up these make_key sequences +def left_pipe_until_some(candidate, functor, *args_iter): + for args in args_iter: + result = functor(candidate, *args) + if result is not None: + return result + + +def first_truthy(candidate, *funcs): + for func in funcs: + result = func(candidate) + if result is not None: + return result + + +def maybe_make_mod_key(candidate, code, names): + if candidate in names: + return make_mod_key(code=code, names=names) + + +def maybe_make_key(candidate, code, names): + if candidate in names: + return make_key(code=code, names=names) + + +def maybe_make_shifted_key(candidate, code, names): + if candidate in names: + return make_shifted_key(code=code, names=names) + + +def maybe_make_consumer_key(candidate, code, names): + if candidate in names: + return make_consumer_key(code=code, names=names) + + +class KeyAttrDict: + __cache = {} + + def __setitem__(self, key, value): + if DEBUG_OUTPUT: + print(f'__setitem__ {key}, {value}') + self.__cache.__setitem__(key, value) + + def __getattr__(self, key): + if DEBUG_OUTPUT: + print(f'__getattr__ {key}') + return self.__getitem__(key) + + def get(self, key, default=None): + try: + return self.__getitem__(key) + except Exception: + return default + + def clear(self): + self.__cache.clear() + + def __getitem__(self, key): + if DEBUG_OUTPUT: + print(f'__getitem__ {key}') + try: + return self.__cache[key] + except Exception: + pass + + key_upper = key.upper() + + # Try all the other weird special cases to get them out of our way: + # This need to be done before or ALPHAS because NO will be parsed as alpha + # Internal, diagnostic, or auxiliary/enhanced keys + + # NO and TRNS are functionally identical in how they (don't) mutate + # the state, but are tracked semantically separately, so create + # two keys with the exact same functionality + if key in ('NO', 'XXXXXXX'): + make_key( + names=('NO', 'XXXXXXX'), + on_press=handlers.passthrough, + on_release=handlers.passthrough, + ) + elif key in ('TRANSPARENT', 'TRNS'): + make_key( + names=('TRANSPARENT', 'TRNS'), + on_press=handlers.passthrough, + on_release=handlers.passthrough, + ) + # Basic ASCII letters/numbers don't need anything fancy, so check those + # in the laziest way + elif key_upper in ALL_ALPHAS: + make_key( + code=4 + ALL_ALPHAS.index(key_upper), + names=( + key_upper, + key.lower(), + ), + ) + elif key in ALL_NUMBERS or key in ALL_NUMBER_ALIASES: + try: + offset = ALL_NUMBERS.index(key) + except ValueError: + offset = ALL_NUMBER_ALIASES.index(key) + + names = (ALL_NUMBERS[offset], ALL_NUMBER_ALIASES[offset]) + make_key(code=30 + offset, names=names) + elif key in ('RESET',): + make_key(names=('RESET',), on_press=handlers.reset) + elif key in ('BOOTLOADER',): + make_key(names=('BOOTLOADER',), on_press=handlers.bootloader) + elif key in ('DEBUG', 'DBG'): + make_key( + names=('DEBUG', 'DBG'), + on_press=handlers.debug_pressed, + on_release=handlers.passthrough, + ) + elif key in ('BKDL',): + make_key( + names=('BKDL',), + on_press=handlers.bkdl_pressed, + on_release=handlers.bkdl_released, + ) + elif key in ('GESC', 'GRAVE_ESC'): + make_key( + names=('GESC', 'GRAVE_ESC'), + on_press=handlers.gesc_pressed, + on_release=handlers.gesc_released, + ) + + # A dummy key to trigger a sleep_ms call in a sequence of other keys in a + # simple sequence macro. + elif key in ('MACRO_SLEEP_MS', 'SLEEP_IN_SEQ'): + make_argumented_key( + validator=key_seq_sleep_validator, + names=('MACRO_SLEEP_MS', 'SLEEP_IN_SEQ'), + on_press=handlers.sleep_pressed, + ) + elif key in ('UC_MODE_NOOP', 'UC_DISABLE'): + make_key( + names=('UC_MODE_NOOP', 'UC_DISABLE'), + meta=UnicodeModeKeyMeta(UnicodeMode.NOOP), + on_press=handlers.uc_mode_pressed, + ) + elif key in ('UC_MODE_LINUX', 'UC_MODE_IBUS'): + make_key( + names=('UC_MODE_LINUX', 'UC_MODE_IBUS'), + meta=UnicodeModeKeyMeta(UnicodeMode.IBUS), + on_press=handlers.uc_mode_pressed, + ) + elif key in ('UC_MODE_MACOS', 'UC_MODE_OSX', 'US_MODE_RALT'): + make_key( + names=('UC_MODE_MACOS', 'UC_MODE_OSX', 'US_MODE_RALT'), + meta=UnicodeModeKeyMeta(UnicodeMode.RALT), + on_press=handlers.uc_mode_pressed, + ) + elif key in ('UC_MODE_WINC',): + make_key( + names=('UC_MODE_WINC',), + meta=UnicodeModeKeyMeta(UnicodeMode.WINC), + on_press=handlers.uc_mode_pressed, + ) + elif key in ('UC_MODE',): + make_argumented_key( + validator=unicode_mode_key_validator, + names=('UC_MODE',), + on_press=handlers.uc_mode_pressed, + ) + elif key in ('HID_SWITCH', 'HID'): + make_key(names=('HID_SWITCH', 'HID'), on_press=handlers.hid_switch) + elif key in ('BLE_REFRESH'): + make_key(names=('BLE_REFRESH',), on_press=handlers.ble_refresh) + else: + maybe_key = first_truthy( + key, + # Modifiers + lambda key: left_pipe_until_some( + key, + maybe_make_mod_key, + (0x01, ('LEFT_CONTROL', 'LCTRL', 'LCTL')), + (0x02, ('LEFT_SHIFT', 'LSHIFT', 'LSFT')), + (0x04, ('LEFT_ALT', 'LALT', 'LOPT')), + (0x08, ('LEFT_SUPER', 'LGUI', 'LCMD', 'LWIN')), + (0x10, ('RIGHT_CONTROL', 'RCTRL', 'RCTL')), + (0x20, ('RIGHT_SHIFT', 'RSHIFT', 'RSFT')), + (0x40, ('RIGHT_ALT', 'RALT', 'ROPT')), + (0x80, ('RIGHT_SUPER', 'RGUI', 'RCMD', 'RWIN')), + # MEH = LCTL | LALT | LSFT# MEH = LCTL | + (0x07, ('MEH',)), + # HYPR = LCTL | LALT | LSFT | LGUI + (0x0F, ('HYPER', 'HYPR')), + ), + lambda key: left_pipe_until_some( + key, + maybe_make_key, + # More ASCII standard keys + (40, ('ENTER', 'ENT', '\n')), + (41, ('ESCAPE', 'ESC')), + (42, ('BACKSPACE', 'BSPC', 'BKSP')), + (43, ('TAB', '\t')), + (44, ('SPACE', 'SPC', ' ')), + (45, ('MINUS', 'MINS', '-')), + (46, ('EQUAL', 'EQL', '=')), + (47, ('LBRACKET', 'LBRC', '[')), + (48, ('RBRACKET', 'RBRC', ']')), + (49, ('BACKSLASH', 'BSLASH', 'BSLS', '\\')), + (51, ('SEMICOLON', 'SCOLON', 'SCLN', ';')), + (52, ('QUOTE', 'QUOT', "'")), + (53, ('GRAVE', 'GRV', 'ZKHK', '`')), + (54, ('COMMA', 'COMM', ',')), + (55, ('DOT', '.')), + (56, ('SLASH', 'SLSH', '/')), + # Function Keys + (58, ('F1',)), + (59, ('F2',)), + (60, ('F3',)), + (61, ('F4',)), + (62, ('F5',)), + (63, ('F6',)), + (64, ('F7',)), + (65, ('F8',)), + (66, ('F9',)), + (67, ('F10',)), + (68, ('F11',)), + (69, ('F12',)), + (104, ('F13',)), + (105, ('F14',)), + (106, ('F15',)), + (107, ('F16',)), + (108, ('F17',)), + (109, ('F18',)), + (110, ('F19',)), + (111, ('F20',)), + (112, ('F21',)), + (113, ('F22',)), + (114, ('F23',)), + (115, ('F24',)), + # Lock Keys, Navigation, etc. + (57, ('CAPS_LOCK', 'CAPSLOCK', 'CLCK', 'CAPS')), + # FIXME: Investigate whether this key actually works, and + # uncomment when/if it does. + # (130, ('LOCKING_CAPS', 'LCAP')), + (70, ('PRINT_SCREEN', 'PSCREEN', 'PSCR')), + (71, ('SCROLL_LOCK', 'SCROLLLOCK', 'SLCK')), + # FIXME: Investigate whether this key actually works, and + # uncomment when/if it does. + # (132, ('LOCKING_SCROLL', 'LSCRL')), + (72, ('PAUSE', 'PAUS', 'BRK')), + (73, ('INSERT', 'INS')), + (74, ('HOME',)), + (75, ('PGUP',)), + (76, ('DELETE', 'DEL')), + (77, ('END',)), + (78, ('PGDOWN', 'PGDN')), + (79, ('RIGHT', 'RGHT')), + (80, ('LEFT',)), + (81, ('DOWN',)), + (82, ('UP',)), + # Numpad + (83, ('NUM_LOCK', 'NUMLOCK', 'NLCK')), + # FIXME: Investigate whether this key actually works, and + # uncomment when/if it does. + # (131, names=('LOCKING_NUM', 'LNUM')), + (84, ('KP_SLASH', 'NUMPAD_SLASH', 'PSLS')), + (85, ('KP_ASTERISK', 'NUMPAD_ASTERISK', 'PAST')), + (86, ('KP_MINUS', 'NUMPAD_MINUS', 'PMNS')), + (87, ('KP_PLUS', 'NUMPAD_PLUS', 'PPLS')), + (88, ('KP_ENTER', 'NUMPAD_ENTER', 'PENT')), + (89, ('KP_1', 'P1', 'NUMPAD_1')), + (90, ('KP_2', 'P2', 'NUMPAD_2')), + (91, ('KP_3', 'P3', 'NUMPAD_3')), + (92, ('KP_4', 'P4', 'NUMPAD_4')), + (93, ('KP_5', 'P5', 'NUMPAD_5')), + (94, ('KP_6', 'P6', 'NUMPAD_6')), + (95, ('KP_7', 'P7', 'NUMPAD_7')), + (96, ('KP_8', 'P8', 'NUMPAD_8')), + (97, ('KP_9', 'P9', 'NUMPAD_9')), + (98, ('KP_0', 'P0', 'NUMPAD_0')), + (99, ('KP_DOT', 'PDOT', 'NUMPAD_DOT')), + (103, ('KP_EQUAL', 'PEQL', 'NUMPAD_EQUAL')), + (133, ('KP_COMMA', 'PCMM', 'NUMPAD_COMMA')), + (134, ('KP_EQUAL_AS400', 'NUMPAD_EQUAL_AS400')), + ), + # Making life better for folks on tiny keyboards especially: exposes + # the 'shifted' keys as raw keys. Under the hood we're still + # sending Shift+(whatever key is normally pressed) to get these, so + # for example `KC_AT` will hold shift and press 2. + lambda key: left_pipe_until_some( + key, + maybe_make_shifted_key, + (30, ('EXCLAIM', 'EXLM', '!')), + (31, ('AT', '@')), + (32, ('HASH', 'POUND', '#')), + (33, ('DOLLAR', 'DLR', '$')), + (34, ('PERCENT', 'PERC', '%')), + (35, ('CIRCUMFLEX', 'CIRC', '^')), + (36, ('AMPERSAND', 'AMPR', '&')), + (37, ('ASTERISK', 'ASTR', '*')), + (38, ('LEFT_PAREN', 'LPRN', '(')), + (39, ('RIGHT_PAREN', 'RPRN', ')')), + (45, ('UNDERSCORE', 'UNDS', '_')), + (46, ('PLUS', '+')), + (47, ('LEFT_CURLY_BRACE', 'LCBR', '{')), + (48, ('RIGHT_CURLY_BRACE', 'RCBR', '}')), + (49, ('PIPE', '|')), + (51, ('COLON', 'COLN', ':')), + (52, ('DOUBLE_QUOTE', 'DQUO', 'DQT', '"')), + (53, ('TILDE', 'TILD', '~')), + (54, ('LEFT_ANGLE_BRACKET', 'LABK', '<')), + (55, ('RIGHT_ANGLE_BRACKET', 'RABK', '>')), + (56, ('QUESTION', 'QUES', '?')), + ), + # International + lambda key: left_pipe_until_some( + key, + maybe_make_key, + (50, ('NONUS_HASH', 'NUHS')), + (100, ('NONUS_BSLASH', 'NUBS')), + (101, ('APP', 'APPLICATION', 'SEL', 'WINMENU')), + (135, ('INT1', 'RO')), + (136, ('INT2', 'KANA')), + (137, ('INT3', 'JYEN')), + (138, ('INT4', 'HENK')), + (139, ('INT5', 'MHEN')), + (140, ('INT6',)), + (141, ('INT7',)), + (142, ('INT8',)), + (143, ('INT9',)), + (144, ('LANG1', 'HAEN')), + (145, ('LANG2', 'HAEJ')), + (146, ('LANG3',)), + (147, ('LANG4',)), + (148, ('LANG5',)), + (149, ('LANG6',)), + (150, ('LANG7',)), + (151, ('LANG8',)), + (152, ('LANG9',)), + ), + # Consumer ("media") keys. Most known keys aren't supported here. A much + # longer list used to exist in this file, but the codes were almost certainly + # incorrect, conflicting with each other, or otherwise 'weird'. We'll add them + # back in piecemeal as needed. PRs welcome. + # + # A super useful reference for these is http://www.freebsddiary.org/APC/usb_hid_usages.php + # Note that currently we only have the PC codes. Recent MacOS versions seem to + # support PC media keys, so I don't know how much value we would get out of + # adding the old Apple-specific consumer codes, but again, PRs welcome if the + # lack of them impacts you. + lambda key: left_pipe_until_some( + key, + maybe_make_consumer_key, + (226, ('AUDIO_MUTE', 'MUTE')), # 0xE2 + (233, ('AUDIO_VOL_UP', 'VOLU')), # 0xE9 + (234, ('AUDIO_VOL_DOWN', 'VOLD')), # 0xEA + (181, ('MEDIA_NEXT_TRACK', 'MNXT')), # 0xB5 + (182, ('MEDIA_PREV_TRACK', 'MPRV')), # 0xB6 + (183, ('MEDIA_STOP', 'MSTP')), # 0xB7 + (205, ('MEDIA_PLAY_PAUSE', 'MPLY')), # 0xCD (this may not be right) + (184, ('MEDIA_EJECT', 'EJCT')), # 0xB8 + (179, ('MEDIA_FAST_FORWARD', 'MFFD')), # 0xB3 + (180, ('MEDIA_REWIND', 'MRWD')), # 0xB4 + ), + ) + + if DEBUG_OUTPUT: + print(f'{key}: {maybe_key}') + + if not maybe_key: + raise ValueError('Invalid key') + + return self.__cache[key] + + +# Global state, will be filled in throughout this file, and +# anywhere the user creates custom keys +KC = KeyAttrDict() + + +class Key: + def __init__( + self, + code, + has_modifiers=None, + no_press=False, + no_release=False, + on_press=handlers.default_pressed, + on_release=handlers.default_released, + meta=object(), + ): + self.code = code + self.has_modifiers = has_modifiers + # cast to bool() in case we get a None value + self.no_press = bool(no_press) + self.no_release = bool(no_release) + + self._handle_press = on_press + self._handle_release = on_release + self.meta = meta + + def __call__(self, no_press=None, no_release=None): + if no_press is None and no_release is None: + return self + + return type(self)( + code=self.code, + has_modifiers=self.has_modifiers, + no_press=no_press, + no_release=no_release, + on_press=self._handle_press, + on_release=self._handle_release, + meta=self.meta, + ) + + def __repr__(self): + return 'Key(code={}, has_modifiers={})'.format(self.code, self.has_modifiers) + + def on_press(self, state, coord_int=None, coord_raw=None): + if hasattr(self, '_pre_press_handlers'): + for fn in self._pre_press_handlers: + if not fn(self, state, KC, coord_int, coord_raw): + return None + + ret = self._handle_press(self, state, KC, coord_int, coord_raw) + + if hasattr(self, '_post_press_handlers'): + for fn in self._post_press_handlers: + fn(self, state, KC, coord_int, coord_raw) + + return ret + + def on_release(self, state, coord_int=None, coord_raw=None): + if hasattr(self, '_pre_release_handlers'): + for fn in self._pre_release_handlers: + if not fn(self, state, KC, coord_int, coord_raw): + return None + + ret = self._handle_release(self, state, KC, coord_int, coord_raw) + + if hasattr(self, '_post_release_handlers'): + for fn in self._post_release_handlers: + fn(self, state, KC, coord_int, coord_raw) + + return ret + + def clone(self): + ''' + Return a shallow clone of the current key without any pre/post press/release + handlers attached. Almost exclusively useful for creating non-colliding keys + to use such handlers. + ''' + + return type(self)( + code=self.code, + has_modifiers=self.has_modifiers, + no_press=self.no_press, + no_release=self.no_release, + on_press=self._handle_press, + on_release=self._handle_release, + meta=self.meta, + ) + + def before_press_handler(self, fn): + ''' + Attach a callback to be run prior to the on_press handler for this key. + Receives the following: + + - self (this Key instance) + - state (the current InternalState) + - KC (the global KC lookup table, for convenience) + - coord_int (an internal integer representation of the matrix coordinate + for the pressed key - this is likely not useful to end users, but is + provided for consistency with the internal handlers) + - coord_raw (an X,Y tuple of the matrix coordinate - also likely not useful) + + If return value of the provided callback is evaluated to False, press + processing is cancelled. Exceptions are _not_ caught, and will likely + crash KMK if not handled within your function. + + These handlers are run in attachment order: handlers provided by earlier + calls of this method will be executed before those provided by later calls. + ''' + + if not hasattr(self, '_pre_press_handlers'): + self._pre_press_handlers = [] + self._pre_press_handlers.append(fn) + return self + + def after_press_handler(self, fn): + ''' + Attach a callback to be run after the on_release handler for this key. + Receives the following: + + - self (this Key instance) + - state (the current InternalState) + - KC (the global KC lookup table, for convenience) + - coord_int (an internal integer representation of the matrix coordinate + for the pressed key - this is likely not useful to end users, but is + provided for consistency with the internal handlers) + - coord_raw (an X,Y tuple of the matrix coordinate - also likely not useful) + + The return value of the provided callback is discarded. Exceptions are _not_ + caught, and will likely crash KMK if not handled within your function. + + These handlers are run in attachment order: handlers provided by earlier + calls of this method will be executed before those provided by later calls. + ''' + + if not hasattr(self, '_post_press_handlers'): + self._post_press_handlers = [] + self._post_press_handlers.append(fn) + return self + + def before_release_handler(self, fn): + ''' + Attach a callback to be run prior to the on_release handler for this + key. Receives the following: + + - self (this Key instance) + - state (the current InternalState) + - KC (the global KC lookup table, for convenience) + - coord_int (an internal integer representation of the matrix coordinate + for the pressed key - this is likely not useful to end users, but is + provided for consistency with the internal handlers) + - coord_raw (an X,Y tuple of the matrix coordinate - also likely not useful) + + If return value of the provided callback evaluates to False, the release + processing is cancelled. Exceptions are _not_ caught, and will likely crash + KMK if not handled within your function. + + These handlers are run in attachment order: handlers provided by earlier + calls of this method will be executed before those provided by later calls. + ''' + + if not hasattr(self, '_pre_release_handlers'): + self._pre_release_handlers = [] + self._pre_release_handlers.append(fn) + return self + + def after_release_handler(self, fn): + ''' + Attach a callback to be run after the on_release handler for this key. + Receives the following: + + - self (this Key instance) + - state (the current InternalState) + - KC (the global KC lookup table, for convenience) + - coord_int (an internal integer representation of the matrix coordinate + for the pressed key - this is likely not useful to end users, but is + provided for consistency with the internal handlers) + - coord_raw (an X,Y tuple of the matrix coordinate - also likely not useful) + + The return value of the provided callback is discarded. Exceptions are _not_ + caught, and will likely crash KMK if not handled within your function. + + These handlers are run in attachment order: handlers provided by earlier + calls of this method will be executed before those provided by later calls. + ''' + + if not hasattr(self, '_post_release_handlers'): + self._post_release_handlers = [] + self._post_release_handlers.append(fn) + return self + + +class ModifierKey(Key): + FAKE_CODE = const(-1) + + def __call__(self, modified_key=None, no_press=None, no_release=None): + if modified_key is None: + return super().__call__(no_press=no_press, no_release=no_release) + + modifiers = set() + code = modified_key.code + + if self.code != ModifierKey.FAKE_CODE: + modifiers.add(self.code) + if self.has_modifiers: + modifiers |= self.has_modifiers + if modified_key.has_modifiers: + modifiers |= modified_key.has_modifiers + + if isinstance(modified_key, ModifierKey): + if modified_key.code != ModifierKey.FAKE_CODE: + modifiers.add(modified_key.code) + code = ModifierKey.FAKE_CODE + + return type(modified_key)( + code=code, + has_modifiers=modifiers, + no_press=no_press, + no_release=no_release, + on_press=modified_key._handle_press, + on_release=modified_key._handle_release, + meta=modified_key.meta, + ) + + def __repr__(self): + return 'ModifierKey(code={}, has_modifiers={})'.format( + self.code, self.has_modifiers + ) + + +class ConsumerKey(Key): + pass + + +def make_key(code=None, names=tuple(), type=KEY_SIMPLE, **kwargs): # NOQA + ''' + Create a new key, aliased by `names` in the KC lookup table. + + If a code is not specified, the key is assumed to be a custom + internal key to be handled in a state callback rather than + sent directly to the OS. These codes will autoincrement. + + Names are globally unique. If a later key is created with + the same name as an existing entry in `KC`, it will overwrite + the existing entry. + + Names are case sensitive. + + All **kwargs are passed to the Key constructor + ''' + + global NEXT_AVAILABLE_KEY + + if type == KEY_SIMPLE: + constructor = Key + elif type == KEY_MODIFIER: + constructor = ModifierKey + elif type == KEY_CONSUMER: + constructor = ConsumerKey + else: + raise ValueError('Unrecognized key type') + + if code is None: + code = NEXT_AVAILABLE_KEY + NEXT_AVAILABLE_KEY += 1 + elif code >= FIRST_KMK_INTERNAL_KEY: + # Try to ensure future auto-generated internal keycodes won't + # be overridden by continuing to +1 the sequence from the provided + # code + NEXT_AVAILABLE_KEY = max(NEXT_AVAILABLE_KEY, code + 1) + + key = constructor(code=code, **kwargs) + + for name in names: + KC[name] = key + + gc.collect() + + return key + + +def make_mod_key(code, names, *args, **kwargs): + return make_key(code, names, *args, **kwargs, type=KEY_MODIFIER) + + +def make_shifted_key(code, names): + return make_key(code, names, has_modifiers={KC.LSFT.code}) + + +def make_consumer_key(*args, **kwargs): + return make_key(*args, **kwargs, type=KEY_CONSUMER) + + +# Argumented keys are implicitly internal, so auto-gen of code +# is almost certainly the best plan here +def make_argumented_key( + validator=lambda *validator_args, **validator_kwargs: object(), + names=tuple(), # NOQA + *constructor_args, + **constructor_kwargs, +): + global NEXT_AVAILABLE_KEY + + def _argumented_key(*user_args, **user_kwargs): + global NEXT_AVAILABLE_KEY + + meta = validator(*user_args, **user_kwargs) + + if meta: + key = Key( + NEXT_AVAILABLE_KEY, meta=meta, *constructor_args, **constructor_kwargs + ) + + NEXT_AVAILABLE_KEY += 1 + + return key + + else: + raise ValueError( + 'Argumented key validator failed for unknown reasons. ' + "This may not be the keymap's fault, as a more specific error " + 'should have been raised.' + ) + + for name in names: + KC[name] = _argumented_key + + return _argumented_key diff --git a/kmk/kmk_keyboard.py b/kmk/kmk_keyboard.py new file mode 100644 index 0000000..a121c27 --- /dev/null +++ b/kmk/kmk_keyboard.py @@ -0,0 +1,502 @@ +from supervisor import ticks_ms + +from kmk.consts import KMK_RELEASE, UnicodeMode +from kmk.hid import BLEHID, USBHID, AbstractHID, HIDModes +from kmk.keys import KC +from kmk.kmktime import ticks_add, ticks_diff +from kmk.scanners.keypad import MatrixScanner + + +class Sandbox: + matrix_update = None + secondary_matrix_update = None + active_layers = None + + +class KMKKeyboard: + ##### + # User-configurable + debug_enabled = False + + keymap = [] + coord_mapping = None + + row_pins = None + col_pins = None + diode_orientation = None + matrix = None + uart_buffer = [] + + unicode_mode = UnicodeMode.NOOP + + modules = [] + extensions = [] + sandbox = Sandbox() + + ##### + # Internal State + keys_pressed = set() + _coordkeys_pressed = {} + hid_type = HIDModes.USB + secondary_hid_type = None + _hid_helper = None + _hid_send_enabled = False + hid_pending = False + matrix_update = None + secondary_matrix_update = None + matrix_update_queue = [] + _matrix_modify = None + state_changed = False + _old_timeouts_len = None + _new_timeouts_len = None + _trigger_powersave_enable = False + _trigger_powersave_disable = False + i2c_deinit_count = 0 + _go_args = None + _processing_timeouts = False + + # this should almost always be PREpended to, replaces + # former use of reversed_active_layers which had pointless + # overhead (the underlying list was never used anyway) + active_layers = [0] + + _timeouts = {} + + # on some M4 setups (such as klardotsh/klarank_feather_m4, CircuitPython + # 6.0rc1) this runs out of RAM every cycle and takes down the board. no + # real known fix yet other than turning off debug, but M4s have always been + # tight on RAM so.... + def __repr__(self): + return ( + 'KMKKeyboard(' + 'debug_enabled={} ' + 'diode_orientation={} ' + 'matrix={} ' + 'unicode_mode={} ' + '_hid_helper={} ' + 'keys_pressed={} ' + 'coordkeys_pressed={} ' + 'hid_pending={} ' + 'active_layers={} ' + 'timeouts={} ' + ')' + ).format( + self.debug_enabled, + self.diode_orientation, + self.matrix, + self.unicode_mode, + self._hid_helper, + # internal state + self.keys_pressed, + self._coordkeys_pressed, + self.hid_pending, + self.active_layers, + self._timeouts, + ) + + def _print_debug_cycle(self, init=False): + if self.debug_enabled: + if init: + print('KMKInit(release={})'.format(KMK_RELEASE)) + print(self) + + def _send_hid(self): + if self._hid_send_enabled: + hid_report = self._hid_helper.create_report(self.keys_pressed) + try: + hid_report.send() + except KeyError as e: + if self.debug_enabled: + print('HidNotFound(HIDReportType={})'.format(e)) + self.hid_pending = False + + def _handle_matrix_report(self, update=None): + if update is not None: + self._on_matrix_changed(update) + self.state_changed = True + + def _find_key_in_map(self, int_coord): + try: + idx = self.coord_mapping.index(int_coord) + except ValueError: + if self.debug_enabled: + print('CoordMappingNotFound(ic={})'.format(int_coord)) + + return None + + for layer in self.active_layers: + try: + layer_key = self.keymap[layer][idx] + except IndexError: + layer_key = None + if self.debug_enabled: + print(f'KeymapIndexError(idx={idx}, layer={layer})') + + if not layer_key or layer_key == KC.TRNS: + continue + + return layer_key + + def _on_matrix_changed(self, kevent): + int_coord = kevent.key_number + is_pressed = kevent.pressed + if self.debug_enabled: + print('MatrixChange(ic={} pressed={})'.format(int_coord, is_pressed)) + + key = None + if not is_pressed: + try: + key = self._coordkeys_pressed[int_coord] + except KeyError: + if self.debug_enabled: + print(f'KeyNotPressed(ic={int_coord})') + + if key is None: + key = self._find_key_in_map(int_coord) + + if key is None: + if self.debug_enabled: + print('MatrixUndefinedCoordinate(ic={})'.format(int_coord)) + return self + + if self.debug_enabled: + print('KeyResolution(key={})'.format(key)) + + self.pre_process_key(key, is_pressed, int_coord) + + def pre_process_key(self, key, is_pressed, int_coord=None): + for module in self.modules: + try: + key = module.process_key(self, key, is_pressed, int_coord) + if key is None: + break + except Exception as err: + if self.debug_enabled: + print('Failed to run process_key function in module: ', err, module) + + if int_coord is not None: + if is_pressed: + self._coordkeys_pressed[int_coord] = key + else: + del self._coordkeys_pressed[int_coord] + + if key: + self.process_key(key, is_pressed, int_coord) + + return self + + def process_key(self, key, is_pressed, coord_int=None): + if is_pressed: + key.on_press(self, coord_int) + else: + key.on_release(self, coord_int) + + return self + + def remove_key(self, keycode): + self.keys_pressed.discard(keycode) + return self.process_key(keycode, False) + + def add_key(self, keycode): + self.keys_pressed.add(keycode) + return self.process_key(keycode, True) + + def tap_key(self, keycode): + self.add_key(keycode) + # On the next cycle, we'll remove the key. + self.set_timeout(False, lambda: self.remove_key(keycode)) + + return self + + def set_timeout(self, after_ticks, callback): + # We allow passing False as an implicit "run this on the next process timeouts cycle" + if after_ticks is False: + after_ticks = 0 + + if after_ticks == 0 and self._processing_timeouts: + after_ticks += 1 + + timeout_key = ticks_add(ticks_ms(), after_ticks) + + if timeout_key not in self._timeouts: + self._timeouts[timeout_key] = [] + + idx = len(self._timeouts[timeout_key]) + self._timeouts[timeout_key].append(callback) + + return (timeout_key, idx) + + def cancel_timeout(self, timeout_key): + try: + self._timeouts[timeout_key[0]][timeout_key[1]] = None + except (KeyError, IndexError): + if self.debug_enabled: + print(f'no such timeout: {timeout_key}') + + def _process_timeouts(self): + if not self._timeouts: + return self + + # Copy timeout keys to a temporary list to allow sorting. + # Prevent net timeouts set during handling from running on the current + # cycle by setting a flag `_processing_timeouts`. + current_time = ticks_ms() + timeout_keys = [] + self._processing_timeouts = True + + for k in self._timeouts.keys(): + if ticks_diff(k, current_time) <= 0: + timeout_keys.append(k) + + for k in sorted(timeout_keys): + for callback in self._timeouts[k]: + if callback: + callback() + del self._timeouts[k] + + self._processing_timeouts = False + + return self + + def _init_sanity_check(self): + ''' + Ensure the provided configuration is *probably* bootable + ''' + assert self.keymap, 'must define a keymap with at least one row' + assert ( + self.hid_type in HIDModes.ALL_MODES + ), 'hid_type must be a value from kmk.consts.HIDModes' + if not self.matrix: + assert self.row_pins, 'no GPIO pins defined for matrix rows' + assert self.col_pins, 'no GPIO pins defined for matrix columns' + assert ( + self.diode_orientation is not None + ), 'diode orientation must be defined' + + return self + + def _init_coord_mapping(self): + ''' + Attempt to sanely guess a coord_mapping if one is not provided. No-op + if `kmk.extensions.split.Split` is used, it provides equivalent + functionality in `on_bootup` + + To save RAM on boards that don't use Split, we don't import Split + and do an isinstance check, but instead do string detection + ''' + if any(x.__class__.__module__ == 'kmk.modules.split' for x in self.modules): + return + + if not self.coord_mapping: + cm = [] + for m in self.matrix: + cm.extend(m.coord_mapping) + self.coord_mapping = tuple(cm) + + def _init_hid(self): + if self.hid_type == HIDModes.NOOP: + self._hid_helper = AbstractHID + elif self.hid_type == HIDModes.USB: + self._hid_helper = USBHID + elif self.hid_type == HIDModes.BLE: + self._hid_helper = BLEHID + else: + self._hid_helper = AbstractHID + self._hid_helper = self._hid_helper(**self._go_args) + self._hid_send_enabled = True + + def _init_matrix(self): + if self.matrix is None: + if self.debug_enabled: + print('Initialising default matrix scanner.') + self.matrix = MatrixScanner( + column_pins=self.col_pins, + row_pins=self.row_pins, + columns_to_anodes=self.diode_orientation, + ) + else: + if self.debug_enabled: + print('Matrix scanner already set, not overwriting.') + + try: + self.matrix = tuple(iter(self.matrix)) + offset = 0 + for matrix in self.matrix: + matrix.offset = offset + offset += matrix.key_count + except TypeError: + self.matrix = (self.matrix,) + + return self + + def before_matrix_scan(self): + for module in self.modules: + try: + module.before_matrix_scan(self) + except Exception as err: + if self.debug_enabled: + print('Failed to run pre matrix function in module: ', err, module) + + for ext in self.extensions: + try: + ext.before_matrix_scan(self.sandbox) + except Exception as err: + if self.debug_enabled: + print('Failed to run pre matrix function in extension: ', err, ext) + + def after_matrix_scan(self): + for module in self.modules: + try: + module.after_matrix_scan(self) + except Exception as err: + if self.debug_enabled: + print('Failed to run post matrix function in module: ', err, module) + + for ext in self.extensions: + try: + ext.after_matrix_scan(self.sandbox) + except Exception as err: + if self.debug_enabled: + print('Failed to run post matrix function in extension: ', err, ext) + + def before_hid_send(self): + for module in self.modules: + try: + module.before_hid_send(self) + except Exception as err: + if self.debug_enabled: + print('Failed to run pre hid function in module: ', err, module) + + for ext in self.extensions: + try: + ext.before_hid_send(self.sandbox) + except Exception as err: + if self.debug_enabled: + print('Failed to run pre hid function in extension: ', err, ext) + + def after_hid_send(self): + for module in self.modules: + try: + module.after_hid_send(self) + except Exception as err: + if self.debug_enabled: + print('Failed to run post hid function in module: ', err, module) + + for ext in self.extensions: + try: + ext.after_hid_send(self.sandbox) + except Exception as err: + if self.debug_enabled: + print('Failed to run post hid function in extension: ', err, ext) + + def powersave_enable(self): + for module in self.modules: + try: + module.on_powersave_enable(self) + except Exception as err: + if self.debug_enabled: + print('Failed to run post hid function in module: ', err, module) + + for ext in self.extensions: + try: + ext.on_powersave_enable(self.sandbox) + except Exception as err: + if self.debug_enabled: + print('Failed to run post hid function in extension: ', err, ext) + + def powersave_disable(self): + for module in self.modules: + try: + module.on_powersave_disable(self) + except Exception as err: + if self.debug_enabled: + print('Failed to run post hid function in module: ', err, module) + for ext in self.extensions: + try: + ext.on_powersave_disable(self.sandbox) + except Exception as err: + if self.debug_enabled: + print('Failed to run post hid function in extension: ', err, ext) + + def go(self, hid_type=HIDModes.USB, secondary_hid_type=None, **kwargs): + self._init(hid_type=hid_type, secondary_hid_type=secondary_hid_type, **kwargs) + while True: + self._main_loop() + + def _init(self, hid_type=HIDModes.USB, secondary_hid_type=None, **kwargs): + self._go_args = kwargs + self.hid_type = hid_type + self.secondary_hid_type = secondary_hid_type + + self._init_sanity_check() + self._init_hid() + self._init_matrix() + self._init_coord_mapping() + + for module in self.modules: + try: + module.during_bootup(self) + except Exception as err: + if self.debug_enabled: + print('Failed to load module', err, module) + print() + for ext in self.extensions: + try: + ext.during_bootup(self) + except Exception as err: + if self.debug_enabled: + print('Failed to load extension', err, ext) + + self._print_debug_cycle(init=True) + + def _main_loop(self): + self.state_changed = False + self.sandbox.active_layers = self.active_layers.copy() + + self.before_matrix_scan() + + for matrix in self.matrix: + update = matrix.scan_for_changes() + if update: + self.matrix_update = update + break + self.sandbox.secondary_matrix_update = self.secondary_matrix_update + + self.after_matrix_scan() + + if self.secondary_matrix_update: + self.matrix_update_queue.append(self.secondary_matrix_update) + self.secondary_matrix_update = None + + if self.matrix_update: + self.matrix_update_queue.append(self.matrix_update) + self.matrix_update = None + + # only handle one key per cycle. + if self.matrix_update_queue: + self._handle_matrix_report(self.matrix_update_queue.pop(0)) + + self.before_hid_send() + + if self.hid_pending: + self._send_hid() + + self._old_timeouts_len = len(self._timeouts) + self._process_timeouts() + self._new_timeouts_len = len(self._timeouts) + + if self._old_timeouts_len != self._new_timeouts_len: + self.state_changed = True + if self.hid_pending: + self._send_hid() + + self.after_hid_send() + + if self._trigger_powersave_enable: + self.powersave_enable() + + if self._trigger_powersave_disable: + self.powersave_disable() + + if self.state_changed: + self._print_debug_cycle() diff --git a/kmk/kmktime.py b/kmk/kmktime.py new file mode 100644 index 0000000..965de65 --- /dev/null +++ b/kmk/kmktime.py @@ -0,0 +1,34 @@ +from micropython import const +from supervisor import ticks_ms + +_TICKS_PERIOD = const(1 << 29) +_TICKS_MAX = const(_TICKS_PERIOD - 1) +_TICKS_HALFPERIOD = const(_TICKS_PERIOD // 2) + + +def ticks_diff(new, start): + diff = (new - start) & _TICKS_MAX + diff = ((diff + _TICKS_HALFPERIOD) & _TICKS_MAX) - _TICKS_HALFPERIOD + return diff + + +def ticks_add(ticks, delta): + return (ticks + delta) % _TICKS_PERIOD + + +def check_deadline(new, start, ms): + return ticks_diff(new, start) < ms + + +class PeriodicTimer: + def __init__(self, period): + self.period = period + self.last_tick = ticks_ms() + + def tick(self): + now = ticks_ms() + if ticks_diff(now, self.last_tick) >= self.period: + self.last_tick = now + return True + else: + return False diff --git a/kmk/modules/__init__.py b/kmk/modules/__init__.py new file mode 100644 index 0000000..b616f07 --- /dev/null +++ b/kmk/modules/__init__.py @@ -0,0 +1,43 @@ +class InvalidExtensionEnvironment(Exception): + pass + + +class Module: + ''' + Modules differ from extensions in that they not only can read the state, but + are allowed to modify the state. The will be loaded on boot, and are not + allowed to be unloaded as they are required to continue functioning in a + consistant manner. + ''' + + # The below methods should be implemented by subclasses + + def during_bootup(self, keyboard): + raise NotImplementedError + + def before_matrix_scan(self, keyboard): + ''' + Return value will be injected as an extra matrix update + ''' + raise NotImplementedError + + def after_matrix_scan(self, keyboard): + ''' + Return value will be replace matrix update if supplied + ''' + raise NotImplementedError + + def process_key(self, keyboard, key, is_pressed, int_coord): + return key + + def before_hid_send(self, keyboard): + raise NotImplementedError + + def after_hid_send(self, keyboard): + raise NotImplementedError + + def on_powersave_enable(self, keyboard): + raise NotImplementedError + + def on_powersave_disable(self, keyboard): + raise NotImplementedError diff --git a/kmk/modules/adns9800.py b/kmk/modules/adns9800.py new file mode 100644 index 0000000..00981c2 --- /dev/null +++ b/kmk/modules/adns9800.py @@ -0,0 +1,241 @@ +import busio +import digitalio +import microcontroller + +import time + +from kmk.modules import Module +from kmk.modules.adns9800_firmware import firmware +from kmk.modules.mouse_keys import PointingDevice + + +class REG: + Product_ID = 0x0 + Revision_ID = 0x1 + MOTION = 0x2 + DELTA_X_L = 0x3 + DELTA_X_H = 0x4 + DELTA_Y_L = 0x5 + DELTA_Y_H = 0x6 + SQUAL = 0x7 + PIXEL_SUM = 0x8 + Maximum_Pixel = 0x9 + Minimum_Pixel = 0xA + Shutter_Lower = 0xB + Shutter_Upper = 0xC + Frame_Period_Lower = 0xD + Frame_Period_Upper = 0xE + Configuration_I = 0xF + Configuration_II = 0x10 + Frame_Capture = 0x12 + SROM_Enable = 0x13 + Run_Downshift = 0x14 + Rest1_Rate = 0x15 + Rest1_Downshift = 0x16 + Rest2_Rate = 0x17 + Rest2_Downshift = 0x18 + Rest3_Rate = 0x19 + Frame_Period_Max_Bound_Lower = 0x1A + Frame_Period_Max_Bound_Upper = 0x1B + Frame_Period_Min_Bound_Lower = 0x1C + Frame_Period_Min_Bound_Upper = 0x1D + Shutter_Max_Bound_Lower = 0x1E + Shutter_Max_Bound_Upper = 0x1F + LASER_CTRL0 = 0x20 + Observation = 0x24 + Data_Out_Lower = 0x25 + Data_Out_Upper = 0x26 + SROM_ID = 0x2A + Lift_Detection_Thr = 0x2E + Configuration_V = 0x2F + Configuration_IV = 0x39 + Power_Up_Reset = 0x3A + Shutdown = 0x3B + Inverse_Product_ID = 0x3F + Snap_Angle = 0x42 + Motion_Burst = 0x50 + SROM_Load_Burst = 0x62 + Pixel_Burst = 0x64 + + +class ADNS9800(Module): + tswr = tsww = 120 + tsrw = tsrr = 20 + tsrad = 100 + tbexit = 1 + baud = 2000000 + cpol = 1 + cpha = 1 + DIR_WRITE = 0x80 + DIR_READ = 0x7F + + def __init__(self, cs, sclk, miso, mosi, invert_x=False, invert_y=False): + self.pointing_device = PointingDevice() + self.cs = digitalio.DigitalInOut(cs) + self.cs.direction = digitalio.Direction.OUTPUT + self.spi = busio.SPI(clock=sclk, MOSI=mosi, MISO=miso) + self.invert_x = invert_x + self.invert_y = invert_y + + def adns_start(self): + self.cs.value = False + + def adns_stop(self): + self.cs.value = True + + def adns_write(self, reg, data): + while not self.spi.try_lock(): + pass + try: + self.spi.configure(baudrate=self.baud, polarity=self.cpol, phase=self.cpha) + self.adns_start() + self.spi.write(bytes([reg | self.DIR_WRITE, data])) + finally: + self.spi.unlock() + self.adns_stop() + + def adns_read(self, reg): + result = bytearray(1) + while not self.spi.try_lock(): + pass + try: + self.spi.configure(baudrate=self.baud, polarity=self.cpol, phase=self.cpha) + self.adns_start() + self.spi.write(bytes([reg & self.DIR_READ])) + microcontroller.delay_us(self.tsrad) + self.spi.readinto(result) + finally: + self.spi.unlock() + self.adns_stop() + + return result[0] + + def adns_upload_srom(self): + while not self.spi.try_lock(): + pass + try: + self.spi.configure(baudrate=self.baud, polarity=self.cpol, phase=self.cpha) + self.adns_start() + self.spi.write(bytes([REG.SROM_Load_Burst | self.DIR_WRITE])) + for b in firmware: + self.spi.write(bytes([b])) + finally: + self.spi.unlock() + self.adns_stop() + + def delta_to_int(self, high, low): + comp = (high << 8) | low + if comp & 0x8000: + return (-1) * (0xFFFF + 1 - comp) + return comp + + def adns_read_motion(self): + result = bytearray(14) + while not self.spi.try_lock(): + pass + try: + self.spi.configure(baudrate=self.baud, polarity=self.cpol, phase=self.cpha) + self.adns_start() + self.spi.write(bytes([REG.Motion_Burst & self.DIR_READ])) + microcontroller.delay_us(self.tsrad) + self.spi.readinto(result) + finally: + self.spi.unlock() + self.adns_stop() + microcontroller.delay_us(self.tbexit) + self.adns_write(REG.MOTION, 0x0) + return result + + def during_bootup(self, keyboard): + + self.adns_write(REG.Power_Up_Reset, 0x5A) + time.sleep(0.1) + self.adns_read(REG.MOTION) + microcontroller.delay_us(self.tsrr) + self.adns_read(REG.DELTA_X_L) + microcontroller.delay_us(self.tsrr) + self.adns_read(REG.DELTA_X_H) + microcontroller.delay_us(self.tsrr) + self.adns_read(REG.DELTA_Y_L) + microcontroller.delay_us(self.tsrr) + self.adns_read(REG.DELTA_Y_H) + microcontroller.delay_us(self.tsrw) + + self.adns_write(REG.Configuration_IV, 0x2) + microcontroller.delay_us(self.tsww) + self.adns_write(REG.SROM_Enable, 0x1D) + microcontroller.delay_us(1000) + self.adns_write(REG.SROM_Enable, 0x18) + microcontroller.delay_us(self.tsww) + + self.adns_upload_srom() + microcontroller.delay_us(2000) + + laser_ctrl0 = self.adns_read(REG.LASER_CTRL0) + microcontroller.delay_us(self.tsrw) + self.adns_write(REG.LASER_CTRL0, laser_ctrl0 & 0xF0) + microcontroller.delay_us(self.tsww) + self.adns_write(REG.Configuration_I, 0x10) + microcontroller.delay_us(self.tsww) + + if keyboard.debug_enabled: + print('ADNS: Product ID ', hex(self.adns_read(REG.Product_ID))) + microcontroller.delay_us(self.tsrr) + print('ADNS: Revision ID ', hex(self.adns_read(REG.Revision_ID))) + microcontroller.delay_us(self.tsrr) + print('ADNS: SROM ID ', hex(self.adns_read(REG.SROM_ID))) + microcontroller.delay_us(self.tsrr) + if self.adns_read(REG.Observation) & 0x20: + print('ADNS: Sensor is running SROM') + else: + print('ADNS: Error! Sensor is not runnin SROM!') + + return + + def before_matrix_scan(self, keyboard): + motion = self.adns_read_motion() + if motion[0] & 0x80: + delta_x = self.delta_to_int(motion[3], motion[2]) + delta_y = self.delta_to_int(motion[5], motion[4]) + + if self.invert_x: + delta_x *= -1 + if self.invert_y: + delta_y *= -1 + + if delta_x < 0: + self.pointing_device.report_x[0] = (delta_x & 0xFF) | 0x80 + else: + self.pointing_device.report_x[0] = delta_x & 0xFF + + if delta_y < 0: + self.pointing_device.report_y[0] = (delta_y & 0xFF) | 0x80 + else: + self.pointing_device.report_y[0] = delta_y & 0xFF + + if keyboard.debug_enabled: + print('Delta: ', delta_x, ' ', delta_y) + self.pointing_device.hid_pending = True + + if self.pointing_device.hid_pending: + keyboard._hid_helper.hid_send(self.pointing_device._evt) + self.pointing_device.hid_pending = False + self.pointing_device.report_x[0] = 0 + self.pointing_device.report_y[0] = 0 + + return + + def after_matrix_scan(self, keyboard): + return + + def before_hid_send(self, keyboard): + return + + def after_hid_send(self, keyboard): + return + + def on_powersave_enable(self, keyboard): + return + + def on_powersave_disable(self, keyboard): + return diff --git a/kmk/modules/capsword.py b/kmk/modules/capsword.py new file mode 100644 index 0000000..0aa4eb1 --- /dev/null +++ b/kmk/modules/capsword.py @@ -0,0 +1,99 @@ +from kmk.keys import FIRST_KMK_INTERNAL_KEY, KC, ModifierKey, make_key +from kmk.modules import Module + + +class CapsWord(Module): + # default timeout is 8000 + # alphabets, numbers and few more keys will not disable capsword + def __init__(self, timeout=8000): + self._alphabets = range(KC.A.code, KC.Z.code) + self._numbers = range(KC.N1.code, KC.N0.code) + self.keys_ignored = [ + KC.MINS, + KC.BSPC, + KC.UNDS, + ] + self._timeout_key = False + self._cw_active = False + self.timeout = timeout + make_key( + names=( + 'CAPSWORD', + 'CW', + ), + on_press=self.cw_pressed, + ) + + def during_bootup(self, keyboard): + return + + def before_matrix_scan(self, keyboard): + return + + def process_key(self, keyboard, key, is_pressed, int_coord): + if self._cw_active and key != KC.CW: + continue_cw = False + # capitalize alphabets + if key.code in self._alphabets: + continue_cw = True + keyboard.process_key(KC.LSFT, is_pressed) + elif ( + key.code in self._numbers + or isinstance(key, ModifierKey) + or key in self.keys_ignored + or key.code + >= FIRST_KMK_INTERNAL_KEY # user defined keys are also ignored + ): + continue_cw = True + # requests and cancels existing timeouts + if is_pressed: + if continue_cw: + self.discard_timeout(keyboard) + self.request_timeout(keyboard) + else: + self.process_timeout() + + return key + + def before_hid_send(self, keyboard): + return + + def after_hid_send(self, keyboard): + return + + def on_powersave_enable(self, keyboard): + return + + def on_powersave_disable(self, keyboard): + return + + def after_matrix_scan(self, keyboard): + return + + def process_timeout(self): + self._cw_active = False + self._timeout_key = False + + def request_timeout(self, keyboard): + if self._cw_active: + if self.timeout: + self._timeout_key = keyboard.set_timeout( + self.timeout, lambda: self.process_timeout() + ) + + def discard_timeout(self, keyboard): + if self._timeout_key: + if self.timeout: + keyboard.cancel_timeout(self._timeout_key) + self._timeout_key = False + + def cw_pressed(self, key, keyboard, *args, **kwargs): + # enables/disables capsword + if key == KC.CW: + if not self._cw_active: + self._cw_active = True + self.discard_timeout(keyboard) + self.request_timeout(keyboard) + else: + self.discard_timeout(keyboard) + self.process_timeout() diff --git a/kmk/modules/cg_swap.py b/kmk/modules/cg_swap.py new file mode 100644 index 0000000..9dbfbc7 --- /dev/null +++ b/kmk/modules/cg_swap.py @@ -0,0 +1,70 @@ +from kmk.keys import KC, ModifierKey, make_key +from kmk.modules import Module + + +class CgSwap(Module): + # default cg swap is disabled, can be eanbled too if needed + def __init__(self, cg_swap_enable=False): + self.cg_swap_enable = cg_swap_enable + self._cg_mapping = { + KC.LCTL: KC.LGUI, + KC.RCTL: KC.RGUI, + KC.LGUI: KC.LCTL, + KC.RGUI: KC.RCTL, + } + make_key( + names=('CG_SWAP',), + ) + make_key( + names=('CG_NORM',), + ) + make_key( + names=('CG_TOGG',), + ) + + def during_bootup(self, keyboard): + return + + def matrix_detected_press(self, keyboard): + return keyboard.matrix_update is None + + def before_matrix_scan(self, keyboard): + return + + def process_key(self, keyboard, key, is_pressed, int_coord): + if is_pressed: + # enables or disables or toggles cg swap + if key == KC.CG_SWAP: + self.cg_swap_enable = True + elif key == KC.CG_NORM: + self.cg_swap_enable = False + elif key == KC.CG_TOGG: + if not self.cg_swap_enable: + self.cg_swap_enable = True + else: + self.cg_swap_enable = False + # performs cg swap + if ( + self.cg_swap_enable + and key not in (KC.CG_SWAP, KC.CG_NORM, KC.CG_TOGG) + and isinstance(key, ModifierKey) + and key in self._cg_mapping + ): + key = self._cg_mapping.get(key) + + return key + + def before_hid_send(self, keyboard): + return + + def after_hid_send(self, keyboard): + return + + def on_powersave_enable(self, keyboard): + return + + def on_powersave_disable(self, keyboard): + return + + def after_matrix_scan(self, keyboard): + return diff --git a/kmk/modules/combos.py b/kmk/modules/combos.py new file mode 100644 index 0000000..8747968 --- /dev/null +++ b/kmk/modules/combos.py @@ -0,0 +1,206 @@ +import kmk.handlers.stock as handlers +from kmk.keys import make_key +from kmk.modules import Module + + +class Combo: + timeout = 50 + per_key_timeout = False + _timeout = None + _remaining = [] + + def __init__(self, match, result, timeout=None, per_key_timeout=None): + ''' + match: tuple of keys (KC.A, KC.B) + result: key KC.C + ''' + self.match = match + self.result = result + if timeout: + self.timeout = timeout + if per_key_timeout: + self.per_key_timeout = per_key_timeout + + def matches(self, key): + raise NotImplementedError + + def reset(self): + self._remaining = list(self.match) + + +class Chord(Combo): + def matches(self, key): + try: + self._remaining.remove(key) + return True + except ValueError: + return False + + +class Sequence(Combo): + timeout = 1000 + per_key_timeout = True + + def matches(self, key): + try: + return key == self._remaining.pop(0) + except IndexError: + return False + + +class Combos(Module): + def __init__(self, combos=[]): + self.combos = combos + self._active = [] + self._matching = [] + self._reset = set() + self._key_buffer = [] + + make_key( + names=('LEADER',), + on_press=handlers.passthrough, + on_release=handlers.passthrough, + ) + + def during_bootup(self, keyboard): + self.reset(keyboard) + + def before_matrix_scan(self, keyboard): + return + + def after_matrix_scan(self, keyboard): + return + + def before_hid_send(self, keyboard): + return + + def after_hid_send(self, keyboard): + return + + def on_powersave_enable(self, keyboard): + return + + def on_powersave_disable(self, keyboard): + return + + def process_key(self, keyboard, key, is_pressed, int_coord): + if is_pressed: + return self.on_press(keyboard, key, int_coord) + else: + return self.on_release(keyboard, key, int_coord) + + def on_press(self, keyboard, key, int_coord): + # refill potential matches from timed-out matches + if not self._matching: + self._matching = list(self._reset) + self._reset = set() + + # filter potential matches + for combo in self._matching.copy(): + if combo.matches(key): + continue + self._matching.remove(combo) + if combo._timeout: + keyboard.cancel_timeout(combo._timeout) + combo._timeout = keyboard.set_timeout( + combo.timeout, lambda c=combo: self.reset_combo(keyboard, c) + ) + + if self._matching: + # At least one combo matches current key: append key to buffer. + self._key_buffer.append((int_coord, key, True)) + key = None + + # Start or reset individual combo timeouts. + for combo in self._matching: + if combo._timeout: + if combo.per_key_timeout: + keyboard.cancel_timeout(combo._timeout) + else: + continue + combo._timeout = keyboard.set_timeout( + combo.timeout, lambda c=combo: self.on_timeout(keyboard, c) + ) + else: + # There's no matching combo: send and reset key buffer + self.send_key_buffer(keyboard) + self._key_buffer = [] + key = keyboard._find_key_in_map(int_coord) + + return key + + def on_release(self, keyboard, key, int_coord): + for combo in self._active: + if key in combo.match: + # Deactivate combo if it matches current key. + self.deactivate(keyboard, combo) + self.reset_combo(keyboard, combo) + key = combo.result + break + + # Don't propagate key-release events for keys that have been buffered. + # Append release events only if corresponding press is in buffer. + else: + pressed = self._key_buffer.count((int_coord, key, True)) + released = self._key_buffer.count((int_coord, key, False)) + if (pressed - released) > 0: + self._key_buffer.append((int_coord, key, False)) + key = None + + return key + + def on_timeout(self, keyboard, combo): + # If combo reaches timeout and has no remaining keys, activate it; + # else, drop it from the match list. + combo._timeout = None + self._matching.remove(combo) + + if not combo._remaining: + self.activate(keyboard, combo) + if any([not pressed for (int_coord, key, pressed) in self._key_buffer]): + # At least one of the combo keys has already been released: + # "tap" the combo result. + keyboard._send_hid() + self.deactivate(keyboard, combo) + self.reset(keyboard) + self._key_buffer = [] + else: + if not self._matching: + # This was the last pending combo: flush key buffer. + self.send_key_buffer(keyboard) + self._key_buffer = [] + self.reset_combo(keyboard, combo) + + def send_key_buffer(self, keyboard): + for (int_coord, key, is_pressed) in self._key_buffer: + try: + new_key = keyboard._coordkeys_pressed[int_coord] + except KeyError: + new_key = None + if new_key is None: + new_key = keyboard._find_key_in_map(int_coord) + + keyboard._coordkeys_pressed[int_coord] = new_key + + keyboard.process_key(new_key, is_pressed) + keyboard._send_hid() + + def activate(self, keyboard, combo): + combo.result.on_press(keyboard) + self._active.append(combo) + + def deactivate(self, keyboard, combo): + combo.result.on_release(keyboard) + self._active.remove(combo) + + def reset_combo(self, keyboard, combo): + combo.reset() + if combo._timeout is not None: + keyboard.cancel_timeout(combo._timeout) + combo._timeout = None + self._reset.add(combo) + + def reset(self, keyboard): + self._matching = [] + for combo in self.combos: + self.reset_combo(keyboard, combo) diff --git a/kmk/modules/easypoint.py b/kmk/modules/easypoint.py new file mode 100644 index 0000000..6a2f9d4 --- /dev/null +++ b/kmk/modules/easypoint.py @@ -0,0 +1,145 @@ +''' +Extension handles usage of AS5013 by AMS +''' + +from supervisor import ticks_ms + +from kmk.modules import Module +from kmk.modules.mouse_keys import PointingDevice + +I2C_ADDRESS = 0x40 +I2X_ALT_ADDRESS = 0x41 + +X = 0x10 +Y_RES_INT = 0x11 + +XP = 0x12 +XN = 0x13 +YP = 0x14 +YN = 0x15 + +M_CTRL = 0x2B +T_CTRL = 0x2D + +Y_OFFSET = 17 +X_OFFSET = 7 + +DEAD_X = 5 +DEAD_Y = 5 + + +class Easypoint(Module): + '''Module handles usage of AS5013 by AMS''' + + def __init__( + self, + i2c, + address=I2C_ADDRESS, + y_offset=Y_OFFSET, + x_offset=X_OFFSET, + dead_x=DEAD_X, + dead_y=DEAD_Y, + ): + self._i2c_address = address + self._i2c_bus = i2c + + # HID parameters + self.pointing_device = PointingDevice() + self.polling_interval = 20 + self.last_tick = ticks_ms() + + # Offsets for poor soldering + self.y_offset = y_offset + self.x_offset = x_offset + + # Deadzone + self.dead_x = DEAD_X + self.dead_y = DEAD_Y + + def during_bootup(self, keyboard): + return + + def before_matrix_scan(self, keyboard): + ''' + Return value will be injected as an extra matrix update + ''' + now = ticks_ms() + if now - self.last_tick < self.polling_interval: + return + self.last_tick = now + + x, y = self._read_raw_state() + + # I'm a shit coder, so offset is handled in software side + s_x = self.getSignedNumber(x, 8) - self.x_offset + s_y = self.getSignedNumber(y, 8) - self.y_offset + + # Evaluate Deadzone + if s_x in range(-self.dead_x, self.dead_x) and s_y in range( + -self.dead_y, self.dead_y + ): + # Within bounds, just die + return + else: + # Set the X/Y from easypoint + self.pointing_device.report_x[0] = x + self.pointing_device.report_y[0] = y + + self.pointing_device.hid_pending = x != 0 or y != 0 + + return + + def after_matrix_scan(self, keyboard): + return + + def before_hid_send(self, keyboard): + return + + def after_hid_send(self, keyboard): + if self.pointing_device.hid_pending: + keyboard._hid_helper.hid_send(self.pointing_device._evt) + self._clear_pending_hid() + return + + def on_powersave_enable(self, keyboard): + return + + def on_powersave_disable(self, keyboard): + return + + def _clear_pending_hid(self): + self.pointing_device.hid_pending = False + self.pointing_device.report_x[0] = 0 + self.pointing_device.report_y[0] = 0 + self.pointing_device.report_w[0] = 0 + self.pointing_device.button_status[0] = 0 + + def _read_raw_state(self): + '''Read data from AS5013''' + x, y = self._i2c_rdwr([X], length=2) + return x, y + + def getSignedNumber(self, number, bitLength=8): + mask = (2 ** bitLength) - 1 + if number & (1 << (bitLength - 1)): + return number | ~mask + else: + return number & mask + + def _i2c_rdwr(self, data, length=1): + '''Write and optionally read I2C data.''' + while not self._i2c_bus.try_lock(): + pass + + try: + if length > 0: + result = bytearray(length) + self._i2c_bus.writeto_then_readfrom( + self._i2c_address, bytes(data), result + ) + return result + else: + self._i2c_bus.writeto(self._i2c_address, bytes(data)) + return [] + finally: + self._i2c_bus.unlock() diff --git a/kmk/modules/encoder.py b/kmk/modules/encoder.py new file mode 100644 index 0000000..c5dfc5f --- /dev/null +++ b/kmk/modules/encoder.py @@ -0,0 +1,289 @@ +# See docs/encoder.md for how to use + +import busio +import digitalio +from supervisor import ticks_ms + +from kmk.modules import Module + +# NB : not using rotaryio as it requires the pins to be consecutive + + +class BaseEncoder: + + VELOCITY_MODE = True + + def __init__(self, is_inverted=False): + + self.is_inverted = is_inverted + + self._state = None + self._direction = None + self._pos = 0 + self._button_state = True + self._button_held = None + self._velocity = 0 + + self._movement = 0 + self._timestamp = ticks_ms() + + # callback functions on events. Need to be defined externally + self.on_move_do = None + self.on_button_do = None + + def get_state(self): + return { + 'direction': self.is_inverted and -self._direction or self._direction, + 'position': self.is_inverted and -self._pos or self._pos, + 'is_pressed': not self._button_state, + 'velocity': self._velocity, + } + + # Called in a loop to refresh encoder state + + def update_state(self): + # Rotation events + new_state = (self.pin_a.get_value(), self.pin_b.get_value()) + + if new_state != self._state: + # it moves ! + self._movement += 1 + # false / false and true / true are common half steps + # looking on the step just before helps determining + # the direction + if new_state[0] == new_state[1] and self._state[0] != self._state[1]: + if new_state[1] == self._state[0]: + self._direction = 1 + else: + self._direction = -1 + + # when the encoder settles on a position (every 2 steps) + if new_state == (True, True): + if self._movement > 2: + # 1 full step is 4 movements, however, when rotated quickly, + # some steps may be missed. This makes it behaves more + # naturally + real_movement = round(self._movement / 4) + self._pos += self._direction * real_movement + if self.on_move_do is not None: + for i in range(real_movement): + self.on_move_do(self.get_state()) + # Reinit to properly identify new movement + self._movement = 0 + self._direction = 0 + + self._state = new_state + + # Velocity + self.velocity_event() + + # Button event + self.button_event() + + def velocity_event(self): + if self.VELOCITY_MODE: + new_timestamp = ticks_ms() + self._velocity = new_timestamp - self._timestamp + self._timestamp = new_timestamp + + def button_event(self): + raise NotImplementedError('subclasses must override button_event()!') + + # return knob velocity as milliseconds between position changes (detents) + # for backwards compatibility + def vel_report(self): + # print(self._velocity) + return self._velocity + + +class GPIOEncoder(BaseEncoder): + def __init__(self, pin_a, pin_b, pin_button=None, is_inverted=False): + super().__init__(is_inverted) + + self.pin_a = EncoderPin(pin_a) + self.pin_b = EncoderPin(pin_b) + self.pin_button = ( + EncoderPin(pin_button, button_type=True) if pin_button is not None else None + ) + + self._state = (self.pin_a.get_value(), self.pin_b.get_value()) + + def button_event(self): + if self.pin_button: + new_button_state = self.pin_button.get_value() + if new_button_state != self._button_state: + self._button_state = new_button_state + if self.on_button_do is not None: + self.on_button_do(self.get_state()) + + +class EncoderPin: + def __init__(self, pin, button_type=False): + self.pin = pin + self.button_type = button_type + self.prepare_pin() + + def prepare_pin(self): + if self.pin is not None: + self.io = digitalio.DigitalInOut(self.pin) + self.io.direction = digitalio.Direction.INPUT + self.io.pull = digitalio.Pull.UP + else: + self.io = None + + def get_value(self): + return self.io.value + + +class I2CEncoder(BaseEncoder): + def __init__(self, i2c, address, is_inverted=False): + + try: + from adafruit_seesaw import digitalio, neopixel, rotaryio, seesaw + except ImportError: + print('seesaw missing') + return + + super().__init__(is_inverted) + + self.seesaw = seesaw.Seesaw(i2c, address) + + # Check for correct product + + seesaw_product = (self.seesaw.get_version() >> 16) & 0xFFFF + if seesaw_product != 4991: + print('Wrong firmware loaded? Expected 4991') + + self.encoder = rotaryio.IncrementalEncoder(self.seesaw) + self.seesaw.pin_mode(24, self.seesaw.INPUT_PULLUP) + self.switch = digitalio.DigitalIO(self.seesaw, 24) + self.pixel = neopixel.NeoPixel(self.seesaw, 6, 1) + + self._state = self.encoder.position + + def update_state(self): + + # Rotation events + new_state = self.encoder.position + if new_state != self._state: + # it moves ! + self._movement += 1 + # false / false and true / true are common half steps + # looking on the step just before helps determining + # the direction + if self.encoder.position > self._state: + self._direction = 1 + else: + self._direction = -1 + self._state = new_state + self.on_move_do(self.get_state()) + + # Velocity + self.velocity_event() + + # Button events + self.button_event() + + def button_event(self): + if not self.switch.value and not self._button_held: + # Pressed + self._button_held = True + if self.on_button_do is not None: + self.on_button_do(self.get_state()) + + if self.switch.value and self._button_held: + # Released + self._button_held = False + + def get_state(self): + return { + 'direction': self.is_inverted and -self._direction or self._direction, + 'position': self._state, + 'is_pressed': not self.switch.value, + 'is_held': self._button_held, + 'velocity': self._velocity, + } + + +class EncoderHandler(Module): + def __init__(self): + self.encoders = [] + self.pins = None + self.map = None + + def on_runtime_enable(self, keyboard): + return + + def on_runtime_disable(self, keyboard): + return + + def during_bootup(self, keyboard): + if self.pins and self.map: + for idx, pins in enumerate(self.pins): + try: + # Check for busio.I2C + if isinstance(pins[0], busio.I2C): + new_encoder = I2CEncoder(*pins) + + # Else fall back to GPIO + else: + gpio_pins = pins[:3] + new_encoder = GPIOEncoder(*gpio_pins) + + # In our case, we need to define keybord and encoder_id for callbacks + new_encoder.on_move_do = lambda x, bound_idx=idx: self.on_move_do( + keyboard, bound_idx, x + ) + new_encoder.on_button_do = ( + lambda x, bound_idx=idx: self.on_button_do( + keyboard, bound_idx, x + ) + ) + self.encoders.append(new_encoder) + except Exception as e: + print(e) + return + + def on_move_do(self, keyboard, encoder_id, state): + if self.map: + layer_id = keyboard.active_layers[0] + # if Left, key index 0 else key index 1 + if state['direction'] == -1: + key_index = 0 + else: + key_index = 1 + key = self.map[layer_id][encoder_id][key_index] + keyboard.tap_key(key) + + def on_button_do(self, keyboard, encoder_id, state): + if state['is_pressed'] is True: + layer_id = keyboard.active_layers[0] + key = self.map[layer_id][encoder_id][2] + keyboard.tap_key(key) + + def before_matrix_scan(self, keyboard): + ''' + Return value will be injected as an extra matrix update + ''' + for encoder in self.encoders: + encoder.update_state() + + return keyboard + + def after_matrix_scan(self, keyboard): + ''' + Return value will be replace matrix update if supplied + ''' + return + + def before_hid_send(self, keyboard): + return + + def after_hid_send(self, keyboard): + return + + def on_powersave_enable(self, keyboard): + return + + def on_powersave_disable(self, keyboard): + return diff --git a/kmk/modules/holdtap.py b/kmk/modules/holdtap.py new file mode 100644 index 0000000..dc6a679 --- /dev/null +++ b/kmk/modules/holdtap.py @@ -0,0 +1,164 @@ +from micropython import const + +from kmk.modules import Module + + +class ActivationType: + PRESSED = const(0) + RELEASED = const(1) + HOLD_TIMEOUT = const(2) + INTERRUPTED = const(3) + + +class HoldTapKeyState: + def __init__(self, timeout_key, *args, **kwargs): + self.timeout_key = timeout_key + self.args = args + self.kwargs = kwargs + self.activated = ActivationType.PRESSED + + +class HoldTap(Module): + tap_time = 300 + + def __init__(self): + self.key_buffer = [] + self.key_states = {} + + def during_bootup(self, keyboard): + return + + def before_matrix_scan(self, keyboard): + return + + def after_matrix_scan(self, keyboard): + return + + def process_key(self, keyboard, key, is_pressed, int_coord): + '''Handle holdtap being interrupted by another key press/release.''' + current_key = key + for key, state in self.key_states.items(): + if key == current_key: + continue + if state.activated != ActivationType.PRESSED: + continue + + # holdtap is interrupted by another key event. + if (is_pressed and not key.meta.tap_interrupted) or ( + not is_pressed and key.meta.tap_interrupted and self.key_buffer + ): + + keyboard.cancel_timeout(state.timeout_key) + self.key_states[key].activated = ActivationType.INTERRUPTED + self.ht_activate_on_interrupt( + key, keyboard, *state.args, **state.kwargs + ) + + keyboard._send_hid() + + self.send_key_buffer(keyboard) + + # if interrupt on release: store interrupting keys until one of them + # is released. + if key.meta.tap_interrupted: + if is_pressed: + self.key_buffer.append((int_coord, current_key)) + current_key = None + + return current_key + + def before_hid_send(self, keyboard): + return + + def after_hid_send(self, keyboard): + return + + def on_powersave_enable(self, keyboard): + return + + def on_powersave_disable(self, keyboard): + return + + def ht_pressed(self, key, keyboard, *args, **kwargs): + '''Do nothing yet, action resolves when key is released, timer expires or other key is pressed.''' + if key.meta.tap_time is None: + tap_time = self.tap_time + else: + tap_time = key.meta.tap_time + timeout_key = keyboard.set_timeout( + tap_time, + lambda: self.on_tap_time_expired(key, keyboard, *args, **kwargs), + ) + self.key_states[key] = HoldTapKeyState(timeout_key, *args, **kwargs) + return keyboard + + def ht_released(self, key, keyboard, *args, **kwargs): + '''On keyup, release mod or tap key.''' + if key in self.key_states: + state = self.key_states[key] + keyboard.cancel_timeout(state.timeout_key) + if state.activated == ActivationType.HOLD_TIMEOUT: + # release hold + self.ht_deactivate_hold(key, keyboard, *args, **kwargs) + elif state.activated == ActivationType.INTERRUPTED: + # release tap + self.ht_deactivate_on_interrupt(key, keyboard, *args, **kwargs) + elif state.activated == ActivationType.PRESSED: + # press and release tap because key released within tap time + self.ht_activate_tap(key, keyboard, *args, **kwargs) + keyboard.set_timeout( + False, + lambda: self.ht_deactivate_tap(key, keyboard, *args, **kwargs), + ) + self.send_key_buffer(keyboard) + del self.key_states[key] + return keyboard + + def on_tap_time_expired(self, key, keyboard, *args, **kwargs): + '''When tap time expires activate hold if key is still being pressed. + Remove key if ActivationType is RELEASED.''' + try: + state = self.key_states[key] + except KeyError: + if keyboard.debug_enabled: + print(f'HoldTap.on_tap_time_expired: no such key {key}') + return + + if self.key_states[key].activated == ActivationType.PRESSED: + # press hold because timer expired after tap time + self.key_states[key].activated = ActivationType.HOLD_TIMEOUT + self.ht_activate_hold(key, keyboard, *args, **kwargs) + self.send_key_buffer(keyboard) + elif state.activated == ActivationType.RELEASED: + self.ht_deactivate_tap(key, keyboard, *args, **kwargs) + del self.key_states[key] + + def send_key_buffer(self, keyboard): + for (int_coord, key) in self.key_buffer: + key.on_press(keyboard) + keyboard._send_hid() + self.key_buffer.clear() + + def ht_activate_hold(self, key, keyboard, *args, **kwargs): + pass + + def ht_deactivate_hold(self, key, keyboard, *args, **kwargs): + pass + + def ht_activate_tap(self, key, keyboard, *args, **kwargs): + pass + + def ht_deactivate_tap(self, key, keyboard, *args, **kwargs): + pass + + def ht_activate_on_interrupt(self, key, keyboard, *args, **kwargs): + if key.meta.prefer_hold: + self.ht_activate_hold(key, keyboard, *args, **kwargs) + else: + self.ht_activate_tap(key, keyboard, *args, **kwargs) + + def ht_deactivate_on_interrupt(self, key, keyboard, *args, **kwargs): + if key.meta.prefer_hold: + self.ht_deactivate_hold(key, keyboard, *args, **kwargs) + else: + self.ht_deactivate_tap(key, keyboard, *args, **kwargs) diff --git a/kmk/modules/layers.py b/kmk/modules/layers.py new file mode 100644 index 0000000..7a7fdc1 --- /dev/null +++ b/kmk/modules/layers.py @@ -0,0 +1,183 @@ +'''One layer isn't enough. Adds keys to get to more of them''' +from micropython import const + +from kmk.key_validators import layer_key_validator +from kmk.keys import make_argumented_key +from kmk.modules.holdtap import ActivationType, HoldTap + + +class LayerType: + '''Defines layer types to be passed on as on_press and on_release kwargs where needed''' + + LT = const(0) + TT = const(1) + + +def curry(fn, *args, **kwargs): + def curried(*fn_args, **fn_kwargs): + merged_args = args + fn_args + merged_kwargs = kwargs.copy() + merged_kwargs.update(fn_kwargs) + return fn(*merged_args, **merged_kwargs) + + return curried + + +class Layers(HoldTap): + '''Gives access to the keys used to enable the layer system''' + + def __init__(self): + # Layers + super().__init__() + make_argumented_key( + validator=layer_key_validator, + names=('MO',), + on_press=self._mo_pressed, + on_release=self._mo_released, + ) + make_argumented_key( + validator=layer_key_validator, names=('DF',), on_press=self._df_pressed + ) + make_argumented_key( + validator=layer_key_validator, + names=('LM',), + on_press=self._lm_pressed, + on_release=self._lm_released, + ) + make_argumented_key( + validator=layer_key_validator, + names=('LT',), + on_press=curry(self.ht_pressed, key_type=LayerType.LT), + on_release=curry(self.ht_released, key_type=LayerType.LT), + ) + make_argumented_key( + validator=layer_key_validator, names=('TG',), on_press=self._tg_pressed + ) + make_argumented_key( + validator=layer_key_validator, names=('TO',), on_press=self._to_pressed + ) + make_argumented_key( + validator=curry(layer_key_validator, prefer_hold=True), + names=('TT',), + on_press=curry(self.ht_pressed, key_type=LayerType.TT), + on_release=curry(self.ht_released, key_type=LayerType.TT), + ) + + def process_key(self, keyboard, key, is_pressed, int_coord): + current_key = super().process_key(keyboard, key, is_pressed, int_coord) + + for key, state in self.key_states.items(): + if key == current_key: + continue + + # on interrupt: key must be translated here, because it was asigned + # before the layer shift happend. + if state.activated == ActivationType.INTERRUPTED: + current_key = keyboard._find_key_in_map(int_coord) + + return current_key + + def send_key_buffer(self, keyboard): + for (int_coord, old_key) in self.key_buffer: + new_key = keyboard._find_key_in_map(int_coord) + + # adding keys late to _coordkeys_pressed isn't pretty, + # but necessary to mitigate race conditions when multiple + # keys are pressed during a tap-interrupted hold-tap. + keyboard._coordkeys_pressed[int_coord] = new_key + new_key.on_press(keyboard) + + keyboard._send_hid() + + self.key_buffer.clear() + + def _df_pressed(self, key, keyboard, *args, **kwargs): + ''' + Switches the default layer + ''' + keyboard.active_layers[-1] = key.meta.layer + + def _mo_pressed(self, key, keyboard, *args, **kwargs): + ''' + Momentarily activates layer, switches off when you let go + ''' + keyboard.active_layers.insert(0, key.meta.layer) + + @staticmethod + def _mo_released(key, keyboard, *args, **kwargs): + # remove the first instance of the target layer + # from the active list + # under almost all normal use cases, this will + # disable the layer (but preserve it if it was triggered + # as a default layer, etc.) + # this also resolves an issue where using DF() on a layer + # triggered by MO() and then defaulting to the MO()'s layer + # would result in no layers active + try: + del_idx = keyboard.active_layers.index(key.meta.layer) + del keyboard.active_layers[del_idx] + except ValueError: + pass + + def _lm_pressed(self, key, keyboard, *args, **kwargs): + ''' + As MO(layer) but with mod active + ''' + keyboard.hid_pending = True + # Sets the timer start and acts like MO otherwise + keyboard.keys_pressed.add(key.meta.kc) + self._mo_pressed(key, keyboard, *args, **kwargs) + + def _lm_released(self, key, keyboard, *args, **kwargs): + ''' + As MO(layer) but with mod active + ''' + keyboard.hid_pending = True + keyboard.keys_pressed.discard(key.meta.kc) + self._mo_released(key, keyboard, *args, **kwargs) + + def _tg_pressed(self, key, keyboard, *args, **kwargs): + ''' + Toggles the layer (enables it if not active, and vise versa) + ''' + # See mo_released for implementation details around this + try: + del_idx = keyboard.active_layers.index(key.meta.layer) + del keyboard.active_layers[del_idx] + except ValueError: + keyboard.active_layers.insert(0, key.meta.layer) + + def _to_pressed(self, key, keyboard, *args, **kwargs): + ''' + Activates layer and deactivates all other layers + ''' + keyboard.active_layers.clear() + keyboard.active_layers.insert(0, key.meta.layer) + + def ht_activate_hold(self, key, keyboard, *args, **kwargs): + key_type = kwargs['key_type'] + if key_type == LayerType.LT: + self._mo_pressed(key, keyboard, *args, **kwargs) + elif key_type == LayerType.TT: + self._tg_pressed(key, keyboard, *args, **kwargs) + + def ht_deactivate_hold(self, key, keyboard, *args, **kwargs): + key_type = kwargs['key_type'] + if key_type == LayerType.LT: + self._mo_released(key, keyboard, *args, **kwargs) + elif key_type == LayerType.TT: + self._tg_pressed(key, keyboard, *args, **kwargs) + + def ht_activate_tap(self, key, keyboard, *args, **kwargs): + key_type = kwargs['key_type'] + if key_type == LayerType.LT: + keyboard.hid_pending = True + keyboard.keys_pressed.add(key.meta.kc) + elif key_type == LayerType.TT: + self._tg_pressed(key, keyboard, *args, **kwargs) + + def ht_deactivate_tap(self, key, keyboard, *args, **kwargs): + key_type = kwargs['key_type'] + if key_type == LayerType.LT: + keyboard.hid_pending = True + keyboard.keys_pressed.discard(key.meta.kc) diff --git a/kmk/modules/midi.py b/kmk/modules/midi.py new file mode 100644 index 0000000..d32ce64 --- /dev/null +++ b/kmk/modules/midi.py @@ -0,0 +1,103 @@ +import adafruit_midi +import usb_midi +from adafruit_midi.control_change import ControlChange +from adafruit_midi.note_off import NoteOff +from adafruit_midi.note_on import NoteOn +from adafruit_midi.pitch_bend import PitchBend +from adafruit_midi.program_change import ProgramChange +from adafruit_midi.start import Start +from adafruit_midi.stop import Stop + +from kmk.keys import make_argumented_key +from kmk.modules import Module + + +class midiNoteValidator: + def __init__(self, note=69, velocity=64, channel=None): + self.note = note + self.velocity = velocity + self.channel = channel + + +class MidiKeys(Module): + def __init__(self): + make_argumented_key( + names=('MIDI_CC',), + validator=ControlChange, + on_press=self.on_press, + ) + + make_argumented_key( + names=('MIDI_NOTE',), + validator=midiNoteValidator, + on_press=self.note_on, + on_release=self.note_off, + ) + + make_argumented_key( + names=('MIDI_PB',), + validator=PitchBend, + on_press=self.on_press, + ) + + make_argumented_key( + names=('MIDI_PC',), + validator=ProgramChange, + on_press=self.on_press, + ) + + make_argumented_key( + names=('MIDI_START',), + validator=Start, + on_press=self.on_press, + ) + + make_argumented_key( + names=('MIDI_STOP',), + validator=Stop, + on_press=self.on_press, + ) + + try: + self.midi = adafruit_midi.MIDI(midi_out=usb_midi.ports[1], out_channel=0) + except IndexError: + self.midi = None + # if debug_enabled: + print('No midi device found.') + + def during_bootup(self, keyboard): + return None + + def before_matrix_scan(self, keyboard): + return None + + def after_matrix_scan(self, keyboard): + return None + + def process_key(self, keyboard, key, is_pressed, int_coord): + return key + + def before_hid_send(self, keyboard): + return None + + def after_hid_send(self, keyboard): + return None + + def on_powersave_enable(self, keyboard): + return None + + def on_powersave_disable(self, keyboard): + return None + + def send(self, message): + if self.midi: + self.midi.send(message) + + def on_press(self, key, keyboard, *args, **kwargs): + self.send(key.meta) + + def note_on(self, key, keyboard, *args, **kwargs): + self.send(NoteOn(key.meta.note, key.meta.velocity, channel=key.meta.channel)) + + def note_off(self, key, keyboard, *args, **kwargs): + self.send(NoteOff(key.meta.note, key.meta.velocity, channel=key.meta.channel)) diff --git a/kmk/modules/modtap.py b/kmk/modules/modtap.py new file mode 100644 index 0000000..15b02a1 --- /dev/null +++ b/kmk/modules/modtap.py @@ -0,0 +1,27 @@ +import kmk.handlers.stock as handlers +from kmk.key_validators import mod_tap_validator +from kmk.keys import make_argumented_key +from kmk.modules.holdtap import HoldTap + + +class ModTap(HoldTap): + def __init__(self): + super().__init__() + make_argumented_key( + validator=mod_tap_validator, + names=('MT',), + on_press=self.ht_pressed, + on_release=self.ht_released, + ) + + def ht_activate_hold(self, key, keyboard, *args, **kwargs): + handlers.default_pressed(key.meta.mods, keyboard, None) + + def ht_deactivate_hold(self, key, keyboard, *args, **kwargs): + handlers.default_released(key.meta.mods, keyboard, None) + + def ht_activate_tap(self, key, keyboard, *args, **kwargs): + handlers.default_pressed(key.meta.kc, keyboard, None) + + def ht_deactivate_tap(self, key, keyboard, *args, **kwargs): + handlers.default_released(key.meta.kc, keyboard, None) diff --git a/kmk/modules/mouse_keys.py b/kmk/modules/mouse_keys.py new file mode 100644 index 0000000..d3cc418 --- /dev/null +++ b/kmk/modules/mouse_keys.py @@ -0,0 +1,245 @@ +from supervisor import ticks_ms + +from kmk.hid import HID_REPORT_SIZES, HIDReportTypes +from kmk.keys import make_key +from kmk.modules import Module + + +class PointingDevice: + MB_LMB = 1 + MB_RMB = 2 + MB_MMB = 4 + _evt = bytearray(HID_REPORT_SIZES[HIDReportTypes.MOUSE] + 1) + + def __init__(self): + self.key_states = {} + self.hid_pending = False + self.report_device = memoryview(self._evt)[0:1] + self.report_device[0] = HIDReportTypes.MOUSE + self.button_status = memoryview(self._evt)[1:2] + self.report_x = memoryview(self._evt)[2:3] + self.report_y = memoryview(self._evt)[3:4] + self.report_w = memoryview(self._evt)[4:] + + +class MouseKeys(Module): + def __init__(self): + self.pointing_device = PointingDevice() + self._nav_key_activated = 0 + self._up_activated = False + self._down_activated = False + self._left_activated = False + self._right_activated = False + self.max_speed = 10 + self.ac_interval = 100 # Delta ms to apply acceleration + self._next_interval = 0 # Time for next tick interval + self.move_step = 1 + + make_key( + names=('MB_LMB',), + on_press=self._mb_lmb_press, + on_release=self._mb_lmb_release, + ) + make_key( + names=('MB_MMB',), + on_press=self._mb_mmb_press, + on_release=self._mb_mmb_release, + ) + make_key( + names=('MB_RMB',), + on_press=self._mb_rmb_press, + on_release=self._mb_rmb_release, + ) + make_key( + names=('MW_UP',), + on_press=self._mw_up_press, + on_release=self._mw_up_release, + ) + make_key( + names=( + 'MW_DOWN', + 'MW_DN', + ), + on_press=self._mw_down_press, + on_release=self._mw_down_release, + ) + make_key( + names=('MS_UP',), + on_press=self._ms_up_press, + on_release=self._ms_up_release, + ) + make_key( + names=( + 'MS_DOWN', + 'MS_DN', + ), + on_press=self._ms_down_press, + on_release=self._ms_down_release, + ) + make_key( + names=( + 'MS_LEFT', + 'MS_LT', + ), + on_press=self._ms_left_press, + on_release=self._ms_left_release, + ) + make_key( + names=( + 'MS_RIGHT', + 'MS_RT', + ), + on_press=self._ms_right_press, + on_release=self._ms_right_release, + ) + + def during_bootup(self, keyboard): + return + + def matrix_detected_press(self, keyboard): + return keyboard.matrix_update is None + + def before_matrix_scan(self, keyboard): + return + + def after_matrix_scan(self, keyboard): + if self._nav_key_activated: + if self._next_interval <= ticks_ms(): + # print("hello: ") + # print(ticks_ms()) + self._next_interval = ticks_ms() + self.ac_interval + # print(self._next_interval) + if self.move_step < self.max_speed: + self.move_step = self.move_step + 1 + if self._right_activated: + self.pointing_device.report_x[0] = self.move_step + if self._left_activated: + self.pointing_device.report_x[0] = 0xFF & (0 - self.move_step) + if self._up_activated: + self.pointing_device.report_y[0] = 0xFF & (0 - self.move_step) + if self._down_activated: + self.pointing_device.report_y[0] = self.move_step + # self.pointing_device.hid_pending = True + return + + def before_hid_send(self, keyboard): + if self.pointing_device.hid_pending: + keyboard._hid_helper.hid_send(self.pointing_device._evt) + return + + def after_hid_send(self, keyboard): + return + + def on_powersave_enable(self, keyboard): + return + + def on_powersave_disable(self, keyboard): + return + + def _mb_lmb_press(self, key, keyboard, *args, **kwargs): + self.pointing_device.button_status[0] |= self.pointing_device.MB_LMB + self.pointing_device.hid_pending = True + + def _mb_lmb_release(self, key, keyboard, *args, **kwargs): + self.pointing_device.button_status[0] &= ~self.pointing_device.MB_LMB + self.pointing_device.hid_pending = True + + def _mb_mmb_press(self, key, keyboard, *args, **kwargs): + self.pointing_device.button_status[0] |= self.pointing_device.MB_MMB + self.pointing_device.hid_pending = True + + def _mb_mmb_release(self, key, keyboard, *args, **kwargs): + self.pointing_device.button_status[0] &= ~self.pointing_device.MB_MMB + self.pointing_device.hid_pending = True + + def _mb_rmb_press(self, key, keyboard, *args, **kwargs): + self.pointing_device.button_status[0] |= self.pointing_device.MB_RMB + self.pointing_device.hid_pending = True + + def _mb_rmb_release(self, key, keyboard, *args, **kwargs): + self.pointing_device.button_status[0] &= ~self.pointing_device.MB_RMB + self.pointing_device.hid_pending = True + + def _mw_up_press(self, key, keyboard, *args, **kwargs): + self.pointing_device.report_w[0] = self.move_step + self.pointing_device.hid_pending = True + + def _mw_up_release(self, key, keyboard, *args, **kwargs): + self.pointing_device.report_w[0] = 0 + self.pointing_device.hid_pending = True + + def _mw_down_press(self, key, keyboard, *args, **kwargs): + self.pointing_device.report_w[0] = 0xFF + self.pointing_device.hid_pending = True + + def _mw_down_release(self, key, keyboard, *args, **kwargs): + self.pointing_device.report_w[0] = 0 + self.pointing_device.hid_pending = True + + # Mouse movement + def _reset_next_interval(self): + if self._nav_key_activated == 1: + self._next_interval = ticks_ms() + self.ac_interval + self.move_step = 1 + + def _check_last(self): + if self._nav_key_activated == 0: + self.move_step = 1 + + def _ms_up_press(self, key, keyboard, *args, **kwargs): + self._nav_key_activated += 1 + self._reset_next_interval() + self._up_activated = True + self.pointing_device.report_y[0] = 0xFF & (0 - self.move_step) + self.pointing_device.hid_pending = True + + def _ms_up_release(self, key, keyboard, *args, **kwargs): + self._up_activated = False + self._nav_key_activated -= 1 + self._check_last() + self.pointing_device.report_y[0] = 0 + self.pointing_device.hid_pending = False + + def _ms_down_press(self, key, keyboard, *args, **kwargs): + self._nav_key_activated += 1 + self._reset_next_interval() + self._down_activated = True + # if not self.x_activated and not self.y_activated: + # self.next_interval = ticks_ms() + self.ac_intervalle + self.pointing_device.report_y[0] = self.move_step + self.pointing_device.hid_pending = True + + def _ms_down_release(self, key, keyboard, *args, **kwargs): + self._down_activated = False + self._nav_key_activated -= 1 + self._check_last() + self.pointing_device.report_y[0] = 0 + self.pointing_device.hid_pending = False + + def _ms_left_press(self, key, keyboard, *args, **kwargs): + self._nav_key_activated += 1 + self._reset_next_interval() + self._left_activated = True + self.pointing_device.report_x[0] = 0xFF & (0 - self.move_step) + self.pointing_device.hid_pending = True + + def _ms_left_release(self, key, keyboard, *args, **kwargs): + self._nav_key_activated -= 1 + self._left_activated = False + self._check_last() + self.pointing_device.report_x[0] = 0 + self.pointing_device.hid_pending = False + + def _ms_right_press(self, key, keyboard, *args, **kwargs): + self._nav_key_activated += 1 + self._reset_next_interval() + self._right_activated = True + self.pointing_device.report_x[0] = self.move_step + self.pointing_device.hid_pending = True + + def _ms_right_release(self, key, keyboard, *args, **kwargs): + self._nav_key_activated -= 1 + self._right_activated = False + self._check_last() + self.pointing_device.report_x[0] = 0 + self.pointing_device.hid_pending = False diff --git a/kmk/modules/oled.py b/kmk/modules/oled.py new file mode 100644 index 0000000..2daaeb0 --- /dev/null +++ b/kmk/modules/oled.py @@ -0,0 +1,85 @@ +import adafruit_ssd1306 +import busio +import time + +from kmk.modules import Module + +class Oled(Module): + def __init__(self, pin1=None, pin2=None, i2c=None, startup_image = None, anim1 = None, anim2 = None): + print("Init OLED") + if i2c is None: + self.i2c = busio.I2C(scl=pin1, sda=pin2) + else: + self.i2c = i2c + self.startup_image = startup_image + self.anims = [anim1, anim2] + self.anim_state = 0 + self.last_state = None + self.oled = adafruit_ssd1306.SSD1306_I2C(128, 32, self.i2c) + self.oled.text("Init", 0, 0, 1) + self.oled.show() + self.inactive_timer = time.time() + self.timeout = 3 + self.inactive_timeout = 30 + self.last_anim = 0 + + + def _oled_blank(self): + self.oled.fill(0) + self.oled.show() + + def _draw_image(self, image, size=(32, 128), offset=0, clear=False): + print("calling draw_image") + if clear: + self.oled.fill(0) + for i in range(size[0]): + for j in range(size[1]): + if image[i][j] is False: + continue + # self.oled.buffer[i + j] = image[i][j] + self.oled.pixel(j+offset, i, 0 if image[i][j] is False else True) + # self.oled_changed = True + self.oled.show() + + def during_bootup(self, keyboard): + if self.startup_image is not None: + self._draw_image(self.startup_image, clear=True) + + print("booting") + keyboard.oled = self + return + + def before_matrix_scan(self, keyboard): + return + + def before_hid_send(self, keyboard): + return + + def after_hid_send(self, keyboard): + return + + def on_powersave_enable(self, keyboard): + return + + def on_powersave_disable(self, keyboard): + return + + def after_matrix_scan(self, keyboard): + curr_time = time.time() + # print(time.time(), self.timeout + self.last_anim ) + if curr_time > self.timeout + self.last_anim and self.last_anim != 0: + self._draw_image(self.startup_image, clear = True) + self.last_anim = 0 + if curr_time > self.inactive_timer + self.inactive_timeout: + self._oled_blank() + return + + def process_key(self, keyboard, key, is_pressed): + curr_time = time.time() + if is_pressed == 1: + # Move the drawing out of process_key + # self._draw_image(self.anims[self.anim_state], (32,32), 64-16, True) + self.anim_state = 1 if self.anim_state == 0 else 0 + self.last_anim = curr_time + self.inactive_timer = curr_time + return super().process_key(keyboard, key, is_pressed) \ No newline at end of file diff --git a/kmk/modules/oled2.py b/kmk/modules/oled2.py new file mode 100644 index 0000000..98dfd0a --- /dev/null +++ b/kmk/modules/oled2.py @@ -0,0 +1,78 @@ +import busio +import terminalio +import displayio +import adafruit_displayio_ssd1306 +import time +from adafruit_display_text import label +from kmk.modules import Module + + + +class Oled(Module): + def __init__(self): + self.pins = None + self.label = label.Label(font=terminalio.FONT, text="", color = 0xFFFFFF) + displayio.release_displays() + self.lock_values = ['x', 'x', 'x'] + self.old_locks_values = None + self.locks = None + self.layer_labels = { + 0: "Def", + 1: "RGB" + } + self.refresh_rate = 0.2 + self.last_update = time.monotonic() + + + + def _init_oled(self, pins): + self.bus = None + self._I2C = busio.I2C(scl=pins[0], sda=pins[1]) + self.display_bus = displayio.I2CDisplay(i2c_bus=self._I2C, device_address=60) + self.display = adafruit_displayio_ssd1306.SSD1306(self.display_bus, width=128, height=32) + self.label.text = "INIT" + self.label.x = 1 + self.label.y = 5 + self.display.rotation = 90 + self.display.show(self.label) + + def _update_locks(self): + self.lock_values[0] = 'N' if self.locks.get_num_lock() else 'n' + self.lock_values[1] = 'C' if self.locks.get_caps_lock() else 'c' + self.lock_values[2] = 'S' if self.locks.get_scroll_lock() else 's' + + def write_text(self, text, label): + pass + + def during_bootup(self, keyboard): + self._init_oled(self.pins) + return + + def after_hid_send(self, keyboard): + t = time.monotonic() + if self.last_update + self.refresh_rate < t: + self._update_locks() + self.label.text = f"{self.lock_values[0]}|{self.lock_values[1]}|{self.lock_values[2]}\n{self.layer_labels[keyboard.sandbox.active_layers[0]]}" + self.last_update = t + # if self.lock_values != self.old_locks_values: + # self.label.text = f"{self.lock_values[0]} {self.lock_values[1]} {self.lock_values[2]}\n{self.layer_labels[keyboard.sandbox.active_layers[0]]}" + # self.old_locks_values = self.lock_values + return + + def before_hid_send(self, keyboard): + return + + def before_matrix_scan(self, keyboard): + + return + + def after_matrix_scan(self, keyboard): + return + + def on_powersave_enable(self, keyboard): + self.display.sleep() + return super() + + def on_powersave_disable(self, keyboard): + self.display.wake() + return \ No newline at end of file diff --git a/kmk/modules/oneshot.py b/kmk/modules/oneshot.py new file mode 100644 index 0000000..8dd7f98 --- /dev/null +++ b/kmk/modules/oneshot.py @@ -0,0 +1,66 @@ +from kmk.keys import make_argumented_key +from kmk.modules.holdtap import ActivationType, HoldTap +from kmk.types import HoldTapKeyMeta + + +def oneshot_validator(kc, tap_time=None): + return HoldTapKeyMeta(kc=kc, prefer_hold=False, tap_time=tap_time) + + +class OneShot(HoldTap): + tap_time = 1000 + + def __init__(self): + super().__init__() + make_argumented_key( + validator=oneshot_validator, + names=('OS', 'ONESHOT'), + on_press=self.osk_pressed, + on_release=self.osk_released, + ) + + def process_key(self, keyboard, current_key, is_pressed, int_coord): + '''Release os key after interrupting keyup.''' + for key, state in self.key_states.items(): + if key == current_key: + continue + + if state.activated == ActivationType.PRESSED and is_pressed: + state.activated = ActivationType.HOLD_TIMEOUT + elif state.activated == ActivationType.RELEASED and is_pressed: + state.activated = ActivationType.INTERRUPTED + elif state.activated == ActivationType.INTERRUPTED: + self.ht_released(key, keyboard) + + return current_key + + def osk_pressed(self, key, keyboard, *args, **kwargs): + '''Register HoldTap mechanism and activate os key.''' + self.ht_pressed(key, keyboard, *args, **kwargs) + self.ht_activate_tap(key, keyboard, *args, **kwargs) + return keyboard + + def osk_released(self, key, keyboard, *args, **kwargs): + '''On keyup, mark os key as released or handle HoldTap.''' + try: + state = self.key_states[key] + except KeyError: + if keyboard.debug_enabled: + print(f'OneShot.osk_released: no such key {key}') + return keyboard + + if state.activated == ActivationType.PRESSED: + state.activated = ActivationType.RELEASED + else: + self.ht_released(key, keyboard, *args, **kwargs) + + return keyboard + + def ht_activate_tap(self, key, keyboard, *args, **kwargs): + keyboard.process_key(key.meta.kc, True) + + def ht_deactivate_tap(self, key, keyboard, *args, **kwargs): + keyboard.process_key(key.meta.kc, False) + + def ht_deactivate_hold(self, key, keyboard, *args, **kwargs): + keyboard.process_key(key.meta.kc, False) diff --git a/kmk/modules/pimoroni_trackball.py b/kmk/modules/pimoroni_trackball.py new file mode 100644 index 0000000..eec22ca --- /dev/null +++ b/kmk/modules/pimoroni_trackball.py @@ -0,0 +1,218 @@ +''' +Extension handles usage of Trackball Breakout by Pimoroni +Product page: https://shop.pimoroni.com/products/trackball-breakout +''' +from micropython import const + +import math +import struct + +from kmk.keys import make_key +from kmk.kmktime import PeriodicTimer +from kmk.modules import Module +from kmk.modules.mouse_keys import PointingDevice + +I2C_ADDRESS = 0x0A +I2C_ADDRESS_ALTERNATIVE = 0x0B + +CHIP_ID = 0xBA11 +VERSION = 1 + +REG_LED_RED = 0x00 +REG_LED_GRN = 0x01 +REG_LED_BLU = 0x02 +REG_LED_WHT = 0x03 + +REG_LEFT = 0x04 +REG_RIGHT = 0x05 +REG_UP = 0x06 +REG_DOWN = 0x07 +REG_SWITCH = 0x08 +MSK_SWITCH_STATE = 0b10000000 + +REG_USER_FLASH = 0xD0 +REG_FLASH_PAGE = 0xF0 +REG_INT = 0xF9 +MSK_INT_TRIGGERED = 0b00000001 +MSK_INT_OUT_EN = 0b00000010 +REG_CHIP_ID_L = 0xFA +RED_CHIP_ID_H = 0xFB +REG_VERSION = 0xFC +REG_I2C_ADDR = 0xFD +REG_CTRL = 0xFE +MSK_CTRL_SLEEP = 0b00000001 +MSK_CTRL_RESET = 0b00000010 +MSK_CTRL_FREAD = 0b00000100 +MSK_CTRL_FWRITE = 0b00001000 + +ANGLE_OFFSET = 0 + + +class TrackballMode: + '''Behaviour mode of trackball: mouse movement or vertical scroll''' + + MOUSE_MODE = const(0) + SCROLL_MODE = const(1) + + +class Trackball(Module): + '''Module handles usage of Trackball Breakout by Pimoroni''' + + def __init__(self, i2c, mode=TrackballMode.MOUSE_MODE, address=I2C_ADDRESS): + self._i2c_address = address + self._i2c_bus = i2c + + self.pointing_device = PointingDevice() + self.mode = mode + self.previous_state = False # click state + self.polling_interval = 20 + + chip_id = struct.unpack('= 0: + self.pointing_device.report_x[0] = x_axis + else: + self.pointing_device.report_x[0] = 0xFF & x_axis + if y_axis >= 0: + self.pointing_device.report_y[0] = y_axis + else: + self.pointing_device.report_y[0] = 0xFF & y_axis + self.pointing_device.hid_pending = x_axis != 0 or y_axis != 0 + else: # SCROLL_MODE + if up >= 0: + self.pointing_device.report_w[0] = up + if down > 0: + self.pointing_device.report_w[0] = 0xFF & (0 - down) + self.pointing_device.hid_pending = up != 0 or down != 0 + + if switch == 1: # Button pressed + self.pointing_device.button_status[0] |= self.pointing_device.MB_LMB + self.pointing_device.hid_pending = True + + if not state and self.previous_state is True: # Button released + self.pointing_device.button_status[0] &= ~self.pointing_device.MB_LMB + self.pointing_device.hid_pending = True + + self.previous_state = state + return + + def after_matrix_scan(self, keyboard): + return + + def before_hid_send(self, keyboard): + return + + def after_hid_send(self, keyboard): + if self.pointing_device.hid_pending: + keyboard._hid_helper.hid_send(self.pointing_device._evt) + self._clear_pending_hid() + return + + def on_powersave_enable(self, keyboard): + return + + def on_powersave_disable(self, keyboard): + return + + def set_rgbw(self, r, g, b, w): + '''Set all LED brightness as RGBW.''' + self._i2c_rdwr([REG_LED_RED, r, g, b, w]) + + def set_red(self, value): + '''Set brightness of trackball red LED.''' + self._i2c_rdwr([REG_LED_RED, value & 0xFF]) + + def set_green(self, value): + '''Set brightness of trackball green LED.''' + self._i2c_rdwr([REG_LED_GRN, value & 0xFF]) + + def set_blue(self, value): + '''Set brightness of trackball blue LED.''' + self._i2c_rdwr([REG_LED_BLU, value & 0xFF]) + + def set_white(self, value): + '''Set brightness of trackball white LED.''' + self._i2c_rdwr([REG_LED_WHT, value & 0xFF]) + + def _clear_pending_hid(self): + self.pointing_device.hid_pending = False + self.pointing_device.report_x[0] = 0 + self.pointing_device.report_y[0] = 0 + self.pointing_device.report_w[0] = 0 + self.pointing_device.button_status[0] = 0 + + def _read_raw_state(self): + '''Read up, down, left, right and switch data from trackball.''' + left, right, up, down, switch = self._i2c_rdwr([REG_LEFT], 5) + switch, switch_state = ( + switch & ~MSK_SWITCH_STATE, + (switch & MSK_SWITCH_STATE) > 0, + ) + return up, down, left, right, switch, switch_state + + def _calculate_movement(self, raw_x, raw_y): + '''Calculate accelerated movement vector from raw data''' + if raw_x == 0 and raw_y == 0: + return 0, 0 + + var_accel = 1 + power = 2.5 + + angle_rad = math.atan2(raw_y, raw_x) + ANGLE_OFFSET + vector_length = math.sqrt(pow(raw_x, 2) + pow(raw_y, 2)) + vector_length = pow(vector_length * var_accel, power) + x = math.floor(vector_length * math.cos(angle_rad)) + y = math.floor(vector_length * math.sin(angle_rad)) + + limit = 127 # hid size limit + x_clamped = max(min(limit, x), -limit) + y_clamped = max(min(limit, y), -limit) + + return x_clamped, y_clamped + + def _i2c_rdwr(self, data, length=0): + '''Write and optionally read I2C data.''' + while not self._i2c_bus.try_lock(): + pass + + try: + if length > 0: + result = bytearray(length) + self._i2c_bus.writeto_then_readfrom( + self._i2c_address, bytes(data), result + ) + return list(result) + else: + self._i2c_bus.writeto(self._i2c_address, bytes(data)) + + return [] + + finally: + self._i2c_bus.unlock() + + def _tb_mode_press(self, key, keyboard, *args, **kwargs): + self.mode = not self.mode diff --git a/kmk/modules/power.py b/kmk/modules/power.py new file mode 100644 index 0000000..aa72eb1 --- /dev/null +++ b/kmk/modules/power.py @@ -0,0 +1,149 @@ +import board +import digitalio +from supervisor import ticks_ms + +from time import sleep + +from kmk.handlers.stock import passthrough as handler_passthrough +from kmk.keys import make_key +from kmk.kmktime import check_deadline +from kmk.modules import Module + + +class Power(Module): + def __init__(self, powersave_pin=None): + self.enable = False + self.powersave_pin = powersave_pin # Powersave pin board object + self._powersave_start = ticks_ms() + self._usb_last_scan = ticks_ms() - 5000 + self._psp = None # Powersave pin object + self._i2c = 0 + self._loopcounter = 0 + + make_key( + names=('PS_TOG',), on_press=self._ps_tog, on_release=handler_passthrough + ) + make_key( + names=('PS_ON',), on_press=self._ps_enable, on_release=handler_passthrough + ) + make_key( + names=('PS_OFF',), on_press=self._ps_disable, on_release=handler_passthrough + ) + + def __repr__(self): + return f'Power({self._to_dict()})' + + def _to_dict(self): + return { + 'enable': self.enable, + 'powersave_pin': self.powersave_pin, + '_powersave_start': self._powersave_start, + '_usb_last_scan': self._usb_last_scan, + '_psp': self._psp, + } + + def during_bootup(self, keyboard): + self._i2c_scan() + + def before_matrix_scan(self, keyboard): + return + + def after_matrix_scan(self, keyboard): + if keyboard.matrix_update or keyboard.secondary_matrix_update: + self.psave_time_reset() + + def before_hid_send(self, keyboard): + return + + def after_hid_send(self, keyboard): + if self.enable: + self.psleep() + + def on_powersave_enable(self, keyboard): + '''Gives 10 cycles to allow other extensions to clean up before powersave''' + if self._loopcounter > 10: + self.enable_powersave(keyboard) + self._loopcounter = 0 + else: + self._loopcounter += 1 + return + + def on_powersave_disable(self, keyboard): + self.disable_powersave(keyboard) + return + + def enable_powersave(self, keyboard): + '''Enables power saving features''' + if keyboard.i2c_deinit_count >= self._i2c and self.powersave_pin: + # Allows power save to prevent RGB drain. + # Example here https://docs.nicekeyboards.com/#/nice!nano/pinout_schematic + + if not self._psp: + self._psp = digitalio.DigitalInOut(self.powersave_pin) + self._psp.direction = digitalio.Direction.OUTPUT + if self._psp: + self._psp.value = True + + self.enable = True + keyboard._trigger_powersave_enable = False + return + + def disable_powersave(self, keyboard): + '''Disables power saving features''' + if self._psp: + self._psp.value = False + # Allows power save to prevent RGB drain. + # Example here https://docs.nicekeyboards.com/#/nice!nano/pinout_schematic + + keyboard._trigger_powersave_disable = False + self.enable = False + return + + def psleep(self): + ''' + Sleeps longer and longer to save power the more time in between updates. + ''' + if check_deadline(ticks_ms(), self._powersave_start) <= 60000: + sleep(8 / 1000) + elif check_deadline(ticks_ms(), self._powersave_start) >= 240000: + sleep(180 / 1000) + return + + def psave_time_reset(self): + self._powersave_start = ticks_ms() + + def _i2c_scan(self): + i2c = board.I2C() + while not i2c.try_lock(): + pass + try: + self._i2c = len(i2c.scan()) + finally: + i2c.unlock() + return + + def usb_rescan_timer(self): + return bool(check_deadline(ticks_ms(), self._usb_last_scan) > 5000) + + def usb_time_reset(self): + self._usb_last_scan = ticks_ms() + return + + def usb_scan(self): + # TODO Add USB detection here. Currently lies that it's connected + # https://github.com/adafruit/circuitpython/pull/3513 + return True + + def _ps_tog(self, key, keyboard, *args, **kwargs): + if self.enable: + keyboard._trigger_powersave_disable = True + else: + keyboard._trigger_powersave_enable = True + + def _ps_enable(self, key, keyboard, *args, **kwargs): + if not self.enable: + keyboard._trigger_powersave_enable = True + + def _ps_disable(self, key, keyboard, *args, **kwargs): + if self.enable: + keyboard._trigger_powersave_disable = True diff --git a/kmk/modules/split.py b/kmk/modules/split.py new file mode 100644 index 0000000..f7ad91a --- /dev/null +++ b/kmk/modules/split.py @@ -0,0 +1,386 @@ +'''Enables splitting keyboards wirelessly or wired''' +import busio +from micropython import const +from supervisor import runtime, ticks_ms + +from keypad import Event as KeyEvent +from storage import getmount + +from kmk.hid import HIDModes +from kmk.kmktime import check_deadline +from kmk.modules import Module + + +class SplitSide: + LEFT = const(1) + RIGHT = const(2) + + +class SplitType: + UART = const(1) + I2C = const(2) # unused + ONEWIRE = const(3) # unused + BLE = const(4) + + +class Split(Module): + '''Enables splitting keyboards wirelessly, or wired''' + + def __init__( + self, + split_flip=True, + split_side=None, + split_type=SplitType.UART, + split_target_left=True, + uart_interval=20, + data_pin=None, + data_pin2=None, + target_left=True, + uart_flip=True, + use_pio=False, + debug_enabled=False, + ): + self._is_target = True + self._uart_buffer = [] + self.split_flip = split_flip + self.split_side = split_side + self.split_type = split_type + self.split_target_left = split_target_left + self.split_offset = None + self.data_pin = data_pin + self.data_pin2 = data_pin2 + self.target_left = target_left + self.uart_flip = uart_flip + self._use_pio = use_pio + self._uart = None + self._uart_interval = uart_interval + self._debug_enabled = debug_enabled + self.uart_header = bytearray([0xB2]) # Any non-zero byte should work + + if self.split_type == SplitType.BLE: + try: + from adafruit_ble import BLERadio + from adafruit_ble.advertising.standard import ( + ProvideServicesAdvertisement, + ) + from adafruit_ble.services.nordic import UARTService + + self.BLERadio = BLERadio + self.ProvideServicesAdvertisement = ProvideServicesAdvertisement + self.UARTService = UARTService + except ImportError: + print('BLE Import error') + return # BLE isn't supported on this platform + self._ble_last_scan = ticks_ms() - 5000 + self._connection_count = 0 + self._split_connected = False + self._uart_connection = None + self._advertisment = None # Seems to not be used anywhere + self._advertising = False + self._psave_enable = False + + if self._use_pio: + from kmk.transports.pio_uart import PIO_UART + + self.PIO_UART = PIO_UART + + def during_bootup(self, keyboard): + # Set up name for target side detection and BLE advertisment + name = str(getmount('/').label) + if self.split_type == SplitType.BLE: + if keyboard.hid_type == HIDModes.BLE: + self._ble = keyboard._hid_helper.ble + else: + self._ble = self.BLERadio() + self._ble.name = name + else: + # Try to guess data pins if not supplied + if not self.data_pin: + self.data_pin = keyboard.data_pin + + # if split side was given, find target from split_side. + if self.split_side == SplitSide.LEFT: + self._is_target = bool(self.split_target_left) + elif self.split_side == SplitSide.RIGHT: + self._is_target = not bool(self.split_target_left) + else: + # Detect split side from name + if ( + self.split_type == SplitType.UART + or self.split_type == SplitType.ONEWIRE + ): + self._is_target = runtime.usb_connected + elif self.split_type == SplitType.BLE: + self._is_target = name.endswith('L') == self.split_target_left + + if name.endswith('L'): + self.split_side = SplitSide.LEFT + elif name.endswith('R'): + self.split_side = SplitSide.RIGHT + + if not self._is_target: + keyboard._hid_send_enabled = False + + if self.split_offset is None: + self.split_offset = keyboard.matrix[-1].coord_mapping[-1] + 1 + + if self.split_type == SplitType.UART and self.data_pin is not None: + if self._is_target or not self.uart_flip: + if self._use_pio: + self._uart = self.PIO_UART(tx=self.data_pin2, rx=self.data_pin) + else: + self._uart = busio.UART( + tx=self.data_pin2, rx=self.data_pin, timeout=self._uart_interval + ) + else: + if self._use_pio: + self._uart = self.PIO_UART(tx=self.data_pin, rx=self.data_pin2) + else: + self._uart = busio.UART( + tx=self.data_pin, rx=self.data_pin2, timeout=self._uart_interval + ) + + # Attempt to sanely guess a coord_mapping if one is not provided. + if not keyboard.coord_mapping: + cm = [] + + rows_to_calc = len(keyboard.row_pins) + cols_to_calc = len(keyboard.col_pins) + + # Flips the col order if PCB is the same but flipped on right + cols_rhs = list(range(cols_to_calc)) + if self.split_flip: + cols_rhs = list(reversed(cols_rhs)) + + for ridx in range(rows_to_calc): + for cidx in range(cols_to_calc): + cm.append(cols_to_calc * ridx + cidx) + for cidx in cols_rhs: + cm.append(cols_to_calc * (rows_to_calc + ridx) + cidx) + + keyboard.coord_mapping = tuple(cm) + + if self.split_side == SplitSide.RIGHT: + offset = self.split_offset + for matrix in keyboard.matrix: + matrix.offset = offset + offset += matrix.key_count + + def before_matrix_scan(self, keyboard): + if self.split_type == SplitType.BLE: + self._check_all_connections(keyboard) + self._receive_ble(keyboard) + elif self.split_type == SplitType.UART: + if self._is_target or self.data_pin2: + self._receive_uart(keyboard) + elif self.split_type == SplitType.ONEWIRE: + pass # Protocol needs written + return + + def after_matrix_scan(self, keyboard): + if keyboard.matrix_update: + if self.split_type == SplitType.UART: + if not self._is_target or self.data_pin2: + self._send_uart(keyboard.matrix_update) + else: + pass # explicit pass just for dev sanity... + elif self.split_type == SplitType.BLE: + self._send_ble(keyboard.matrix_update) + elif self.split_type == SplitType.ONEWIRE: + pass # Protocol needs written + else: + print('Unexpected case in after_matrix_scan') + + return + + def before_hid_send(self, keyboard): + if not self._is_target: + keyboard.hid_pending = False + + return + + def after_hid_send(self, keyboard): + return + + def on_powersave_enable(self, keyboard): + if self.split_type == SplitType.BLE: + if self._uart_connection and not self._psave_enable: + self._uart_connection.connection_interval = self._uart_interval + self._psave_enable = True + + def on_powersave_disable(self, keyboard): + if self.split_type == SplitType.BLE: + if self._uart_connection and self._psave_enable: + self._uart_connection.connection_interval = 11.25 + self._psave_enable = False + + def _check_all_connections(self, keyboard): + '''Validates the correct number of BLE connections''' + self._previous_connection_count = self._connection_count + self._connection_count = len(self._ble.connections) + if self._is_target: + if self._advertising or not self._check_if_split_connected(): + self._target_advertise() + elif self._connection_count < 2 and keyboard.hid_type == HIDModes.BLE: + keyboard._hid_helper.start_advertising() + + elif not self._is_target and self._connection_count < 1: + self._initiator_scan() + + def _check_if_split_connected(self): + # I'm looking for a way how to recognize which connection is on and which one off + # For now, I found that service name relation to having other CP device + if self._connection_count == 0: + return False + if self._connection_count == 2: + self._split_connected = True + return True + + # Polling this takes some time so I check only if connection_count changed + if self._previous_connection_count == self._connection_count: + return self._split_connected + + bleio_connection = self._ble.connections[0]._bleio_connection + connection_services = bleio_connection.discover_remote_services() + for service in connection_services: + if str(service.uuid).startswith("UUID('adaf0001"): + self._split_connected = True + return True + return False + + def _initiator_scan(self): + '''Scans for target device''' + self._uart = None + self._uart_connection = None + # See if any existing connections are providing UARTService. + self._connection_count = len(self._ble.connections) + if self._connection_count > 0 and not self._uart: + for connection in self._ble.connections: + if self.UARTService in connection: + self._uart_connection = connection + self._uart_connection.connection_interval = 11.25 + self._uart = self._uart_connection[self.UARTService] + break + + if not self._uart: + if self._debug_enabled: + print('Scanning') + self._ble.stop_scan() + for adv in self._ble.start_scan( + self.ProvideServicesAdvertisement, timeout=20 + ): + if self._debug_enabled: + print('Scanning') + if self.UARTService in adv.services and adv.rssi > -70: + self._uart_connection = self._ble.connect(adv) + self._uart_connection.connection_interval = 11.25 + self._uart = self._uart_connection[self.UARTService] + self._ble.stop_scan() + if self._debug_enabled: + print('Scan complete') + break + self._ble.stop_scan() + + def _target_advertise(self): + '''Advertises the target for the initiator to find''' + # Give previous advertising some time to complete + if self._advertising: + if self._check_if_split_connected(): + if self._debug_enabled: + print('Advertising complete') + self._ble.stop_advertising() + self._advertising = False + return + + if not self.ble_rescan_timer(): + return + + if self._debug_enabled: + print('Advertising not answered') + + self._ble.stop_advertising() + if self._debug_enabled: + print('Advertising') + # Uart must not change on this connection if reconnecting + if not self._uart: + self._uart = self.UARTService() + advertisement = self.ProvideServicesAdvertisement(self._uart) + + self._ble.start_advertising(advertisement) + self._advertising = True + self.ble_time_reset() + + def ble_rescan_timer(self): + '''If true, the rescan timer is up''' + return not bool(check_deadline(ticks_ms(), self._ble_last_scan, 5000)) + + def ble_time_reset(self): + '''Resets the rescan timer''' + self._ble_last_scan = ticks_ms() + + def _serialize_update(self, update): + buffer = bytearray(2) + buffer[0] = update.key_number + buffer[1] = update.pressed + return buffer + + def _deserialize_update(self, update): + kevent = KeyEvent(key_number=update[0], pressed=update[1]) + return kevent + + def _send_ble(self, update): + if self._uart: + try: + self._uart.write(self._serialize_update(update)) + except OSError: + try: + self._uart.disconnect() + except: # noqa: E722 + if self._debug_enabled: + print('UART disconnect failed') + + if self._debug_enabled: + print('Connection error') + self._uart_connection = None + self._uart = None + + def _receive_ble(self, keyboard): + if self._uart is not None and self._uart.in_waiting > 0 or self._uart_buffer: + while self._uart.in_waiting >= 2: + update = self._deserialize_update(self._uart.read(2)) + self._uart_buffer.append(update) + if self._uart_buffer: + keyboard.secondary_matrix_update = self._uart_buffer.pop(0) + + def _checksum(self, update): + checksum = bytes([sum(update) & 0xFF]) + + return checksum + + def _send_uart(self, update): + # Change offsets depending on where the data is going to match the correct + # matrix location of the receiever + if self._uart is not None: + update = self._serialize_update(update) + self._uart.write(self.uart_header) + self._uart.write(update) + self._uart.write(self._checksum(update)) + + def _receive_uart(self, keyboard): + if self._uart is not None and self._uart.in_waiting > 0 or self._uart_buffer: + if self._uart.in_waiting >= 60: + # This is a dirty hack to prevent crashes in unrealistic cases + import microcontroller + + microcontroller.reset() + + while self._uart.in_waiting >= 4: + # Check the header + if self._uart.read(1) == self.uart_header: + update = self._uart.read(2) + + # check the checksum + if self._checksum(update) == self._uart.read(1): + self._uart_buffer.append(self._deserialize_update(update)) + if self._uart_buffer: + keyboard.secondary_matrix_update = self._uart_buffer.pop(0) diff --git a/kmk/modules/tapdance.py b/kmk/modules/tapdance.py new file mode 100644 index 0000000..859a62d --- /dev/null +++ b/kmk/modules/tapdance.py @@ -0,0 +1,124 @@ +from kmk.key_validators import tap_dance_key_validator +from kmk.keys import make_argumented_key +from kmk.modules import Module +from kmk.types import TapDanceKeyMeta + + +class TapDance(Module): + # User-configurable + tap_time = 300 + + # Internal State + _tapping = False + _tap_dance_counts = {} + _tap_timeout = None + _tap_side_effects = {} + + def __init__(self): + make_argumented_key( + validator=tap_dance_key_validator, + names=('TAP_DANCE', 'TD'), + on_press=self.td_pressed, + on_release=self.td_released, + ) + + def during_bootup(self, keyboard): + return + + def before_matrix_scan(self, keyboard): + return + + def after_matrix_scan(self, keyboard): + return + + def before_hid_send(self, keyboard): + return + + def after_hid_send(self, keyboard): + return + + def on_powersave_enable(self, keyboard): + return + + def on_powersave_disable(self, keyboard): + return + + def process_key(self, keyboard, key, is_pressed, int_coord): + if self._tapping and is_pressed and not isinstance(key.meta, TapDanceKeyMeta): + for k, v in self._tap_dance_counts.items(): + if v: + self._end_tap_dance(k, keyboard, hold=True) + keyboard.hid_pending = True + keyboard._send_hid() + keyboard.set_timeout( + False, lambda: keyboard.process_key(key, is_pressed) + ) + return None + + return key + + def td_pressed(self, key, keyboard, *args, **kwargs): + if key not in self._tap_dance_counts or not self._tap_dance_counts[key]: + self._tap_dance_counts[key] = 1 + self._tapping = True + else: + keyboard.cancel_timeout(self._tap_timeout) + self._tap_dance_counts[key] += 1 + + if key not in self._tap_side_effects: + self._tap_side_effects[key] = None + + self._tap_timeout = keyboard.set_timeout( + self.tap_time, lambda: self._end_tap_dance(key, keyboard, hold=True) + ) + + return self + + def td_released(self, key, keyboard, *args, **kwargs): + has_side_effects = self._tap_side_effects[key] is not None + hit_max_defined_taps = self._tap_dance_counts[key] == len(key.meta.codes) + + keyboard.cancel_timeout(self._tap_timeout) + if has_side_effects or hit_max_defined_taps: + self._end_tap_dance(key, keyboard) + else: + self._tap_timeout = keyboard.set_timeout( + self.tap_time, lambda: self._end_tap_dance(key, keyboard) + ) + + return self + + def _end_tap_dance(self, key, keyboard, hold=False): + v = self._tap_dance_counts[key] - 1 + + if v < 0: + return self + + if key in keyboard.keys_pressed: + key_to_press = key.meta.codes[v] + keyboard.add_key(key_to_press) + self._tap_side_effects[key] = key_to_press + elif self._tap_side_effects[key]: + keyboard.remove_key(self._tap_side_effects[key]) + self._tap_side_effects[key] = None + self._cleanup_tap_dance(key) + elif hold is False: + if key.meta.codes[v] in keyboard.keys_pressed: + keyboard.remove_key(key.meta.codes[v]) + else: + keyboard.tap_key(key.meta.codes[v]) + self._cleanup_tap_dance(key) + else: + key_to_press = key.meta.codes[v] + keyboard.add_key(key_to_press) + self._tap_side_effects[key] = key_to_press + self._tapping = 0 + + keyboard.hid_pending = True + + return self + + def _cleanup_tap_dance(self, key): + self._tap_dance_counts[key] = 0 + self._tapping = any(count > 0 for count in self._tap_dance_counts.values()) + return self diff --git a/kmk/scanners/__init__.py b/kmk/scanners/__init__.py new file mode 100644 index 0000000..e3815be --- /dev/null +++ b/kmk/scanners/__init__.py @@ -0,0 +1,42 @@ +def intify_coordinate(row, col, len_cols): + return len_cols * row + col + + +class DiodeOrientation: + ''' + Orientation of diodes on handwired boards. You can think of: + COLUMNS = vertical + ROWS = horizontal + + COL2ROW and ROW2COL are equivalent to their meanings in QMK. + ''' + + COLUMNS = 0 + ROWS = 1 + COL2ROW = COLUMNS + ROW2COL = ROWS + + +class Scanner: + ''' + Base class for scanners. + ''' + + # for split keyboards, the offset value will be assigned in Split module + offset = 0 + + @property + def coord_mapping(self): + return tuple(range(self.offset, self.offset + self.key_count)) + + @property + def key_count(self): + raise NotImplementedError + + def scan_for_changes(self): + ''' + Scan for key events and return a key report if an event exists. + + The key report is a byte array with contents [row, col, True if pressed else False] + ''' + raise NotImplementedError diff --git a/kmk/scanners/digitalio.py b/kmk/scanners/digitalio.py new file mode 100644 index 0000000..b9c85b6 --- /dev/null +++ b/kmk/scanners/digitalio.py @@ -0,0 +1,144 @@ +import digitalio + +from keypad import Event as KeyEvent + +from kmk.scanners import DiodeOrientation, Scanner + + +class MatrixScanner(Scanner): + def __init__( + self, + cols, + rows, + diode_orientation=DiodeOrientation.COLUMNS, + rollover_cols_every_rows=None, + offset=0, + ): + self.len_cols = len(cols) + self.len_rows = len(rows) + self.offset = offset + + # 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 + # + # repr() hackery is because CircuitPython Pin objects are not hashable + unique_pins = {repr(c) for c in cols} | {repr(r) for r in rows} + assert ( + len(unique_pins) == self.len_cols + self.len_rows + ), 'Cannot use a pin as both a column and row' + del unique_pins + + self.diode_orientation = diode_orientation + + # __class__.__name__ is used instead of isinstance as the MCP230xx lib + # does not use the digitalio.DigitalInOut, but rather a self defined one: + # https://github.com/adafruit/Adafruit_CircuitPython_MCP230xx/blob/3f04abbd65ba5fa938fcb04b99e92ae48a8c9406/adafruit_mcp230xx/digital_inout.py#L33 + + if self.diode_orientation == DiodeOrientation.COLUMNS: + self.outputs = [ + x + if x.__class__.__name__ == 'DigitalInOut' + else digitalio.DigitalInOut(x) + for x in cols + ] + self.inputs = [ + x + if x.__class__.__name__ == 'DigitalInOut' + else digitalio.DigitalInOut(x) + for x in rows + ] + self.translate_coords = True + elif self.diode_orientation == DiodeOrientation.ROWS: + self.outputs = [ + x + if x.__class__.__name__ == 'DigitalInOut' + else digitalio.DigitalInOut(x) + for x in rows + ] + self.inputs = [ + x + if x.__class__.__name__ == 'DigitalInOut' + else digitalio.DigitalInOut(x) + for x in cols + ] + self.translate_coords = False + else: + raise ValueError( + 'Invalid DiodeOrientation: {}'.format(self.diode_orientation) + ) + + for pin in self.outputs: + pin.switch_to_output() + + for pin in self.inputs: + pin.switch_to_input(pull=digitalio.Pull.DOWN) + + self.rollover_cols_every_rows = rollover_cols_every_rows + if self.rollover_cols_every_rows is None: + self.rollover_cols_every_rows = self.len_rows + + self._key_count = self.len_cols * self.len_rows + self.state = bytearray(self.key_count) + + @property + def key_count(self): + return self._key_count + + def scan_for_changes(self): + ''' + Poll the matrix for changes and return either None (if nothing updated) + or a bytearray (reused in later runs so copy this if you need the raw + array itself for some crazy reason) consisting of (row, col, pressed) + which are (int, int, bool) + ''' + ba_idx = 0 + any_changed = False + + for oidx, opin in enumerate(self.outputs): + opin.value = True + + for iidx, ipin in enumerate(self.inputs): + # cast to int to avoid + # + # >>> xyz = bytearray(3) + # >>> xyz[2] = True + # Traceback (most recent call last): + # File "", line 1, in + # OverflowError: value would overflow a 1 byte buffer + # + # I haven't dived too far into what causes this, but it's + # almost certainly because bool types in Python aren't just + # aliases to int values, but are proper pseudo-types + new_val = int(ipin.value) + old_val = self.state[ba_idx] + + if old_val != new_val: + if self.translate_coords: + new_oidx = oidx + self.len_cols * ( + iidx // self.rollover_cols_every_rows + ) + new_iidx = iidx - self.rollover_cols_every_rows * ( + iidx // self.rollover_cols_every_rows + ) + + row = new_iidx + col = new_oidx + else: + row = oidx + col = iidx + + pressed = new_val + self.state[ba_idx] = new_val + + any_changed = True + break + + ba_idx += 1 + + opin.value = False + if any_changed: + break + + if any_changed: + key_number = self.len_cols * row + col + self.offset + return KeyEvent(key_number, pressed) diff --git a/kmk/scanners/encoder.py b/kmk/scanners/encoder.py new file mode 100644 index 0000000..301ebb6 --- /dev/null +++ b/kmk/scanners/encoder.py @@ -0,0 +1,43 @@ +import keypad +import rotaryio + +from kmk.scanners import Scanner + + +class RotaryioEncoder(Scanner): + def __init__(self, pin_a, pin_b, divisor=4): + self.encoder = rotaryio.IncrementalEncoder(pin_a, pin_b, divisor) + self.position = 0 + self._pressed = False + self._queue = [] + + @property + def key_count(self): + return 2 + + def scan_for_changes(self): + position = self.encoder.position + + if position != self.position: + self._queue.append(position - self.position) + self.position = position + + if not self._queue: + return + + key_number = self.offset + if self._queue[0] > 0: + key_number += 1 + + if self._pressed: + self._queue[0] -= 1 if self._queue[0] > 0 else -1 + + if self._queue[0] == 0: + self._queue.pop(0) + + self._pressed = False + + else: + self._pressed = True + + return keypad.Event(key_number, self._pressed) diff --git a/kmk/scanners/keypad.py b/kmk/scanners/keypad.py new file mode 100644 index 0000000..43da0a4 --- /dev/null +++ b/kmk/scanners/keypad.py @@ -0,0 +1,113 @@ +import keypad + +from kmk.scanners import DiodeOrientation, Scanner + + +class KeypadScanner(Scanner): + ''' + Translation layer around a CircuitPython 7 keypad scanner. + + :param pin_map: A sequence of (row, column) tuples for each key. + :param kp: An instance of the keypad class. + ''' + + def __init__(self): + self.curr_event = keypad.Event() + + @property + def key_count(self): + return self.keypad.key_count + + def scan_for_changes(self): + ''' + Scan for key events and return a key report if an event exists. + + The key report is a byte array with contents [row, col, True if pressed else False] + ''' + ev = self.curr_event + has_event = self.keypad.events.get_into(ev) + if has_event: + if self.offset: + return keypad.Event(ev.key_number + self.offset, ev.pressed) + else: + return ev + + +class MatrixScanner(KeypadScanner): + ''' + Row/Column matrix using the CircuitPython 7 keypad scanner. + + :param row_pins: A sequence of pins used for rows. + :param col_pins: A sequence of pins used for columns. + :param direction: The diode orientation of the matrix. + ''' + + def __init__( + self, + row_pins, + column_pins, + *, + columns_to_anodes=DiodeOrientation.COL2ROW, + interval=0.02, + max_events=64, + ): + self.keypad = keypad.KeyMatrix( + row_pins, + column_pins, + columns_to_anodes=(columns_to_anodes == DiodeOrientation.COL2ROW), + interval=interval, + max_events=max_events, + ) + super().__init__() + + +class KeysScanner(KeypadScanner): + ''' + GPIO-per-key 'matrix' using the native CircuitPython 7 keypad scanner. + + :param pins: An array of arrays of CircuitPython Pin objects, such that pins[r][c] is the pin for row r, column c. + ''' + + def __init__( + self, + pins, + *, + value_when_pressed=False, + pull=True, + interval=0.02, + max_events=64, + ): + self.keypad = keypad.Keys( + pins, + value_when_pressed=value_when_pressed, + pull=pull, + interval=interval, + max_events=max_events, + ) + super().__init__() + + +class ShiftRegisterKeys(KeypadScanner): + def __init__( + self, + *, + clock, + data, + latch, + value_to_latch=True, + key_count, + value_when_pressed=False, + interval=0.02, + max_events=64, + ): + self.keypad = keypad.ShiftRegisterKeys( + clock=clock, + data=data, + latch=latch, + value_to_latch=value_to_latch, + key_count=key_count, + value_when_pressed=value_when_pressed, + interval=interval, + max_events=max_events, + ) + super().__init__() diff --git a/kmk/transports/__init__.py b/kmk/transports/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/kmk/transports/pio_uart.py b/kmk/transports/pio_uart.py new file mode 100644 index 0000000..ac9eadc --- /dev/null +++ b/kmk/transports/pio_uart.py @@ -0,0 +1,90 @@ +''' +Circuit Python wrapper around PIO implementation of UART +Original source of these examples: https://github.com/adafruit/Adafruit_CircuitPython_PIOASM/tree/main/examples (MIT) +''' +import adafruit_pioasm +import rp2pio + +tx_code = adafruit_pioasm.assemble( + ''' +.program uart_tx +.side_set 1 opt +; An 8n1 UART transmit program. +; OUT pin 0 and side-set pin 0 are both mapped to UART TX pin. + pull side 1 [7] ; Assert stop bit, or stall with line in idle state + set x, 7 side 0 [7] ; Preload bit counter, assert start bit for 8 clocks +bitloop: ; This loop will run 8 times (8n1 UART) + out pins, 1 ; Shift 1 bit from OSR to the first OUT pin + jmp x-- bitloop [6] ; Each loop iteration is 8 cycles. +''' +) + +rx_code = adafruit_pioasm.assemble( + ''' +.program uart_rx_mini + +; Minimum viable 8n1 UART receiver. Wait for the start bit, then sample 8 bits +; with the correct timing. +; IN pin 0 is mapped to the GPIO used as UART RX. +; Autopush must be enabled, with a threshold of 8. + + wait 0 pin 0 ; Wait for start bit + set x, 7 [10] ; Preload bit counter, delay until eye of first data bit +bitloop: ; Loop 8 times + in pins, 1 ; Sample data + jmp x-- bitloop [6] ; Each iteration is 8 cycles + +''' +) + + +class PIO_UART: + def __init__(self, *, tx, rx, baudrate=9600): + if tx: + self.tx_pio = rp2pio.StateMachine( + tx_code, + first_out_pin=tx, + first_sideset_pin=tx, + frequency=8 * baudrate, + initial_sideset_pin_state=1, + initial_sideset_pin_direction=1, + initial_out_pin_state=1, + initial_out_pin_direction=1, + sideset_enable=True, + ) + if rx: + self.rx_pio = rp2pio.StateMachine( + rx_code, + first_in_pin=rx, + frequency=8 * baudrate, + auto_push=True, + push_threshold=8, + ) + + @property + def timeout(self): + return 0 + + @property + def baudrate(self): + return self.tx_pio.frequency // 8 + + @baudrate.setter + def baudrate(self, frequency): + self.tx_pio.frequency = frequency * 8 + self.rx_pio.frequency = frequency * 8 + + def write(self, buf): + return self.tx_pio.write(buf) + + @property + def in_waiting(self): + return self.rx_pio.in_waiting + + def read(self, n): + b = bytearray(n) + n = self.rx_pio.readinto(b) + return b[:n] + + def readinto(self, buf): + return self.rx_pio.readinto(buf) diff --git a/kmk/types.py b/kmk/types.py new file mode 100644 index 0000000..d64fece --- /dev/null +++ b/kmk/types.py @@ -0,0 +1,51 @@ +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] + + +class HoldTapKeyMeta: + def __init__(self, kc=None, prefer_hold=True, tap_interrupted=False, tap_time=None): + self.kc = kc + self.prefer_hold = prefer_hold + self.tap_interrupted = tap_interrupted + self.tap_time = tap_time + + +class LayerKeyMeta(HoldTapKeyMeta): + def __init__(self, layer, **kwargs): + super().__init__(**kwargs) + self.layer = layer + + +class ModTapKeyMeta(HoldTapKeyMeta): + def __init__(self, kc=None, mods=None, **kwargs): + super().__init__(kc=kc, **kwargs) + self.mods = mods + + +class KeySequenceMeta: + def __init__(self, seq): + self.seq = seq + + +class KeySeqSleepMeta: + def __init__(self, ms): + self.ms = ms + + +class UnicodeModeKeyMeta: + def __init__(self, mode): + self.mode = mode + + +class TapDanceKeyMeta: + def __init__(self, codes): + self.codes = codes diff --git a/kmk/utils.py b/kmk/utils.py new file mode 100644 index 0000000..872db37 --- /dev/null +++ b/kmk/utils.py @@ -0,0 +1,2 @@ +def clamp(x, bottom=0, top=100): + return min(max(bottom, x), top) diff --git a/lib/adafruit_bitmap_font/__init__.py b/lib/adafruit_bitmap_font/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/lib/adafruit_bitmap_font/bdf.mpy b/lib/adafruit_bitmap_font/bdf.mpy new file mode 100644 index 0000000..33bbd9f Binary files /dev/null and b/lib/adafruit_bitmap_font/bdf.mpy differ diff --git a/lib/adafruit_bitmap_font/bitmap_font.mpy b/lib/adafruit_bitmap_font/bitmap_font.mpy new file mode 100644 index 0000000..fec35b4 Binary files /dev/null and b/lib/adafruit_bitmap_font/bitmap_font.mpy differ diff --git a/lib/adafruit_bitmap_font/glyph_cache.mpy b/lib/adafruit_bitmap_font/glyph_cache.mpy new file mode 100644 index 0000000..a2e3f2c Binary files /dev/null and b/lib/adafruit_bitmap_font/glyph_cache.mpy differ diff --git a/lib/adafruit_bitmap_font/pcf.mpy b/lib/adafruit_bitmap_font/pcf.mpy new file mode 100644 index 0000000..98f1dec Binary files /dev/null and b/lib/adafruit_bitmap_font/pcf.mpy differ diff --git a/lib/adafruit_bitmap_font/ttf.mpy b/lib/adafruit_bitmap_font/ttf.mpy new file mode 100644 index 0000000..e91cee2 Binary files /dev/null and b/lib/adafruit_bitmap_font/ttf.mpy differ diff --git a/lib/adafruit_display_text/__init__.py b/lib/adafruit_display_text/__init__.py new file mode 100644 index 0000000..0cd91be --- /dev/null +++ b/lib/adafruit_display_text/__init__.py @@ -0,0 +1,465 @@ +# SPDX-FileCopyrightText: 2020 Tim C, 2021 Jeff Epler for Adafruit Industries +# +# SPDX-License-Identifier: MIT + +""" +`adafruit_display_text` +======================= +""" + +try: + from typing import Optional, Union, List, Tuple + from fontio import BuiltinFont + from adafruit_bitmap_font.bdf import BDF + from adafruit_bitmap_font.pcf import PCF +except ImportError: + pass +from displayio import Group, Palette + + +def wrap_text_to_pixels( + string: str, + max_width: int, + font: Optional[Union[BuiltinFont, BDF, PCF]] = None, + indent0: str = "", + indent1: str = "", +) -> List[str]: + # pylint: disable=too-many-branches, too-many-locals, too-many-nested-blocks, too-many-statements + + """wrap_text_to_pixels function + A helper that will return a list of lines with word-break wrapping. + Leading and trailing whitespace in your string will be removed. If + you wish to use leading whitespace see ``indent0`` and ``indent1`` + parameters. + + :param str string: The text to be wrapped. + :param int max_width: The maximum number of pixels on a line before wrapping. + :param font: The font to use for measuring the text. + :type font: ~BuiltinFont, ~BDF, or ~PCF + :param str indent0: Additional character(s) to add to the first line. + :param str indent1: Additional character(s) to add to all other lines. + + :return: A list of the lines resulting from wrapping the + input text at ``max_width`` pixels size + :rtype: List[str] + + """ + if font is None: + + def measure(text): + return len(text) + + else: + if hasattr(font, "load_glyphs"): + font.load_glyphs(string) + + def measure(text): + return sum(font.get_glyph(ord(c)).shift_x for c in text) + + lines = [] + partial = [indent0] + width = measure(indent0) + swidth = measure(" ") + firstword = True + for line_in_input in string.split("\n"): + newline = True + for index, word in enumerate(line_in_input.split(" ")): + wwidth = measure(word) + word_parts = [] + cur_part = "" + + if wwidth > max_width: + for char in word: + if newline: + extraspace = 0 + leadchar = "" + else: + extraspace = swidth + leadchar = " " + if ( + measure("".join(partial)) + + measure(cur_part) + + measure(char) + + measure("-") + + extraspace + > max_width + ): + if cur_part: + word_parts.append( + "".join(partial) + leadchar + cur_part + "-" + ) + + else: + word_parts.append("".join(partial)) + cur_part = char + partial = [indent1] + newline = True + else: + cur_part += char + if cur_part: + word_parts.append(cur_part) + for line in word_parts[:-1]: + lines.append(line) + partial.append(word_parts[-1]) + width = measure(word_parts[-1]) + if firstword: + firstword = False + else: + if firstword: + partial.append(word) + firstword = False + width += wwidth + elif width + swidth + wwidth < max_width: + if index > 0: + partial.append(" ") + partial.append(word) + width += wwidth + swidth + else: + lines.append("".join(partial)) + partial = [indent1, word] + width = measure(indent1) + wwidth + if newline: + newline = False + + lines.append("".join(partial)) + partial = [indent1] + width = measure(indent1) + + return lines + + +def wrap_text_to_lines(string: str, max_chars: int) -> List[str]: + """wrap_text_to_lines function + A helper that will return a list of lines with word-break wrapping + + :param str string: The text to be wrapped + :param int max_chars: The maximum number of characters on a line before wrapping + + :return: A list of lines where each line is separated based on the amount + of ``max_chars`` provided + :rtype: List[str] + """ + + def chunks(lst, n): + """Yield successive n-sized chunks from lst.""" + for i in range(0, len(lst), n): + yield lst[i : i + n] + + string = string.replace("\n", "").replace("\r", "") # Strip confusing newlines + words = string.split(" ") + the_lines = [] + the_line = "" + for w in words: + if len(w) > max_chars: + if the_line: # add what we had stored + the_lines.append(the_line) + parts = [] + for part in chunks(w, max_chars - 1): + parts.append("{}-".format(part)) + the_lines.extend(parts[:-1]) + the_line = parts[-1][:-1] + continue + + if len(the_line + " " + w) <= max_chars: + the_line += " " + w + elif not the_line and len(w) == max_chars: + the_lines.append(w) + else: + the_lines.append(the_line) + the_line = "" + w + if the_line: # Last line remaining + the_lines.append(the_line) + # Remove any blank lines + while not the_lines[0]: + del the_lines[0] + # Remove first space from first line: + if the_lines[0][0] == " ": + the_lines[0] = the_lines[0][1:] + return the_lines + + +class LabelBase(Group): + # pylint: disable=too-many-instance-attributes + + """Superclass that all other types of labels will extend. This contains + all of the properties and functions that work the same way in all labels. + + **Note:** This should be treated as an abstract base class. + + Subclasses should implement ``_set_text``, ``_set_font``, and ``_set_line_spacing`` to + have the correct behavior for that type of label. + + :param font: A font class that has ``get_bounding_box`` and ``get_glyph``. + Must include a capital M for measuring character size. + :type font: ~BuiltinFont, ~BDF, or ~PCF + :param str text: Text to display + :param int color: Color of all text in RGB hex + :param int background_color: Color of the background, use `None` for transparent + :param float line_spacing: Line spacing of text to display + :param bool background_tight: Set `True` only if you want background box to tightly + surround text. When set to 'True' Padding parameters will be ignored. + :param int padding_top: Additional pixels added to background bounding box at top + :param int padding_bottom: Additional pixels added to background bounding box at bottom + :param int padding_left: Additional pixels added to background bounding box at left + :param int padding_right: Additional pixels added to background bounding box at right + :param (float,float) anchor_point: Point that anchored_position moves relative to. + Tuple with decimal percentage of width and height. + (E.g. (0,0) is top left, (1.0, 0.5): is middle right.) + :param (int,int) anchored_position: Position relative to the anchor_point. Tuple + containing x,y pixel coordinates. + :param int scale: Integer value of the pixel scaling + :param bool base_alignment: when True allows to align text label to the baseline. + This is helpful when two or more labels need to be aligned to the same baseline + :param (int,str) tab_replacement: tuple with tab character replace information. When + (4, " ") will indicate a tab replacement of 4 spaces, defaults to 4 spaces by + tab character + :param str label_direction: string defining the label text orientation. See the + subclass documentation for the possible values.""" + + def __init__( + self, + font: Union[BuiltinFont, BDF, PCF], + x: int = 0, + y: int = 0, + text: str = "", + color: int = 0xFFFFFF, + background_color: int = None, + line_spacing: float = 1.25, + background_tight: bool = False, + padding_top: int = 0, + padding_bottom: int = 0, + padding_left: int = 0, + padding_right: int = 0, + anchor_point: Tuple[float, float] = None, + anchored_position: Tuple[int, int] = None, + scale: int = 1, + base_alignment: bool = False, + tab_replacement: Tuple[int, str] = (4, " "), + label_direction: str = "LTR", + **kwargs, # pylint: disable=unused-argument + ) -> None: + # pylint: disable=too-many-arguments, too-many-locals + + super().__init__(x=x, y=y, scale=1) + + self._font = font + self._text = text + self._palette = Palette(2) + self._color = 0xFFFFFF + self._background_color = None + self._line_spacing = line_spacing + self._background_tight = background_tight + self._padding_top = padding_top + self._padding_bottom = padding_bottom + self._padding_left = padding_left + self._padding_right = padding_right + self._anchor_point = anchor_point + self._anchored_position = anchored_position + self._base_alignment = base_alignment + self._label_direction = label_direction + self._tab_replacement = tab_replacement + self._tab_text = self._tab_replacement[1] * self._tab_replacement[0] + + if "max_glyphs" in kwargs: + print("Please update your code: 'max_glyphs' is not needed anymore.") + + self._ascent, self._descent = self._get_ascent_descent() + self._bounding_box = None + + self.color = color + self.background_color = background_color + + # local group will hold background and text + # the self group scale should always remain at 1, the self._local_group will + # be used to set the scale of the label + self._local_group = Group(scale=scale) + self.append(self._local_group) + + self._baseline = -1.0 + + if self._base_alignment: + self._y_offset = 0 + else: + self._y_offset = self._ascent // 2 + + def _get_ascent_descent(self) -> Tuple[int, int]: + """Private function to calculate ascent and descent font values""" + if hasattr(self.font, "ascent") and hasattr(self.font, "descent"): + return self.font.ascent, self.font.descent + + # check a few glyphs for maximum ascender and descender height + glyphs = "M j'" # choose glyphs with highest ascender and lowest + try: + self._font.load_glyphs(glyphs) + except AttributeError: + # Builtin font doesn't have or need load_glyphs + pass + # descender, will depend upon font used + ascender_max = descender_max = 0 + for char in glyphs: + this_glyph = self._font.get_glyph(ord(char)) + if this_glyph: + ascender_max = max(ascender_max, this_glyph.height + this_glyph.dy) + descender_max = max(descender_max, -this_glyph.dy) + return ascender_max, descender_max + + @property + def font(self) -> Union[BuiltinFont, BDF, PCF]: + """Font to use for text display.""" + return self._font + + def _set_font(self, new_font: Union[BuiltinFont, BDF, PCF]) -> None: + raise NotImplementedError("{} MUST override '_set_font'".format(type(self))) + + @font.setter + def font(self, new_font: Union[BuiltinFont, BDF, PCF]) -> None: + self._set_font(new_font) + + @property + def color(self) -> int: + """Color of the text as an RGB hex number.""" + return self._color + + @color.setter + def color(self, new_color: int): + self._color = new_color + if new_color is not None: + self._palette[1] = new_color + self._palette.make_opaque(1) + else: + self._palette[1] = 0 + self._palette.make_transparent(1) + + @property + def background_color(self) -> int: + """Color of the background as an RGB hex number.""" + return self._background_color + + def _set_background_color(self, new_color): + raise NotImplementedError( + "{} MUST override '_set_background_color'".format(type(self)) + ) + + @background_color.setter + def background_color(self, new_color: int) -> None: + self._set_background_color(new_color) + + @property + def anchor_point(self) -> Tuple[float, float]: + """Point that anchored_position moves relative to. + Tuple with decimal percentage of width and height. + (E.g. (0,0) is top left, (1.0, 0.5): is middle right.)""" + return self._anchor_point + + @anchor_point.setter + def anchor_point(self, new_anchor_point: Tuple[float, float]) -> None: + if new_anchor_point[1] == self._baseline: + self._anchor_point = (new_anchor_point[0], -1.0) + else: + self._anchor_point = new_anchor_point + + # update the anchored_position using setter + self.anchored_position = self._anchored_position + + @property + def anchored_position(self) -> Tuple[int, int]: + """Position relative to the anchor_point. Tuple containing x,y + pixel coordinates.""" + return self._anchored_position + + @anchored_position.setter + def anchored_position(self, new_position: Tuple[int, int]) -> None: + self._anchored_position = new_position + # Calculate (x,y) position + if (self._anchor_point is not None) and (self._anchored_position is not None): + self.x = int( + new_position[0] + - (self._bounding_box[0] * self.scale) + - round(self._anchor_point[0] * (self._bounding_box[2] * self.scale)) + ) + if self._anchor_point[1] == self._baseline: + self.y = int(new_position[1] - (self._y_offset * self.scale)) + else: + self.y = int( + new_position[1] + - (self._bounding_box[1] * self.scale) + - round(self._anchor_point[1] * self._bounding_box[3] * self.scale) + ) + + @property + def scale(self) -> int: + """Set the scaling of the label, in integer values""" + return self._local_group.scale + + @scale.setter + def scale(self, new_scale: int) -> None: + self._local_group.scale = new_scale + self.anchored_position = self._anchored_position # update the anchored_position + + def _set_text(self, new_text: str, scale: int) -> None: + raise NotImplementedError("{} MUST override '_set_text'".format(type(self))) + + @property + def text(self) -> str: + """Text to be displayed.""" + return self._text + + @text.setter # Cannot set color or background color with text setter, use separate setter + def text(self, new_text: str) -> None: + self._set_text(new_text, self.scale) + + @property + def bounding_box(self) -> Tuple[int, int]: + """An (x, y, w, h) tuple that completely covers all glyphs. The + first two numbers are offset from the x, y origin of this group""" + return tuple(self._bounding_box) + + @property + def height(self) -> int: + """The height of the label determined from the bounding box.""" + return self._bounding_box[3] - self._bounding_box[1] + + @property + def width(self) -> int: + """The width of the label determined from the bounding box.""" + return self._bounding_box[2] - self._bounding_box[0] + + @property + def line_spacing(self) -> float: + """The amount of space between lines of text, in multiples of the font's + bounding-box height. (E.g. 1.0 is the bounding-box height)""" + return self._line_spacing + + def _set_line_spacing(self, new_line_spacing: float) -> None: + raise NotImplementedError( + "{} MUST override '_set_line_spacing'".format(type(self)) + ) + + @line_spacing.setter + def line_spacing(self, new_line_spacing: float) -> None: + self._set_line_spacing(new_line_spacing) + + @property + def label_direction(self) -> str: + """Set the text direction of the label""" + return self._label_direction + + def _set_label_direction(self, new_label_direction: str) -> None: + raise NotImplementedError( + "{} MUST override '_set_label_direction'".format(type(self)) + ) + + def _get_valid_label_directions(self) -> Tuple[str, ...]: + raise NotImplementedError( + "{} MUST override '_get_valid_label_direction'".format(type(self)) + ) + + @label_direction.setter + def label_direction(self, new_label_direction: str) -> None: + """Set the text direction of the label""" + if new_label_direction not in self._get_valid_label_directions(): + raise RuntimeError("Please provide a valid text direction") + self._set_label_direction(new_label_direction) + + def _replace_tabs(self, text: str) -> str: + return text if text.find("\t") < 0 else self._tab_text.join(text.split("\t")) diff --git a/lib/adafruit_display_text/bitmap_label.py b/lib/adafruit_display_text/bitmap_label.py new file mode 100644 index 0000000..1f99ba0 --- /dev/null +++ b/lib/adafruit_display_text/bitmap_label.py @@ -0,0 +1,571 @@ +# SPDX-FileCopyrightText: 2020 Kevin Matocha +# +# SPDX-License-Identifier: MIT + +""" +`adafruit_display_text.bitmap_label` +================================================================================ + +Text graphics handling for CircuitPython, including text boxes + + +* Author(s): Kevin Matocha + +Implementation Notes +-------------------- + +**Hardware:** + +**Software and Dependencies:** + +* Adafruit CircuitPython firmware for the supported boards: + https://circuitpython.org/downloads + +""" + +__version__ = "2.22.3" +__repo__ = "https://github.com/adafruit/Adafruit_CircuitPython_Display_Text.git" + + +try: + from typing import Union, Optional, Tuple + from fontio import BuiltinFont + from adafruit_bitmap_font.bdf import BDF + from adafruit_bitmap_font.pcf import PCF +except ImportError: + pass + +import displayio + +from adafruit_display_text import LabelBase + +# pylint: disable=too-many-instance-attributes +class Label(LabelBase): + """A label displaying a string of text that is stored in a bitmap. + Note: This ``bitmap_label.py`` library utilizes a :py:class:`~displayio.Bitmap` + to display the text. This method is memory-conserving relative to ``label.py``. + + For further reduction in memory usage, set ``save_text=False`` (text string will not + be stored and ``line_spacing`` and ``font`` are immutable with ``save_text`` + set to ``False``). + + The origin point set by ``x`` and ``y`` + properties will be the left edge of the bounding box, and in the center of a M + glyph (if its one line), or the (number of lines * linespacing + M)/2. That is, + it will try to have it be center-left as close as possible. + + :param font: A font class that has ``get_bounding_box`` and ``get_glyph``. + Must include a capital M for measuring character size. + :type font: ~BuiltinFont, ~BDF, or ~PCF + :param str text: Text to display + :param int color: Color of all text in RGB hex + :param int background_color: Color of the background, use `None` for transparent + :param float line_spacing: Line spacing of text to display + :param bool background_tight: Set `True` only if you want background box to tightly + surround text. When set to 'True' Padding parameters will be ignored. + :param int padding_top: Additional pixels added to background bounding box at top + :param int padding_bottom: Additional pixels added to background bounding box at bottom + :param int padding_left: Additional pixels added to background bounding box at left + :param int padding_right: Additional pixels added to background bounding box at right + :param (float,float) anchor_point: Point that anchored_position moves relative to. + Tuple with decimal percentage of width and height. + (E.g. (0,0) is top left, (1.0, 0.5): is middle right.) + :param (int,int) anchored_position: Position relative to the anchor_point. Tuple + containing x,y pixel coordinates. + :param int scale: Integer value of the pixel scaling + :param bool save_text: Set True to save the text string as a constant in the + label structure. Set False to reduce memory use. + :param bool base_alignment: when True allows to align text label to the baseline. + This is helpful when two or more labels need to be aligned to the same baseline + :param (int,str) tab_replacement: tuple with tab character replace information. When + (4, " ") will indicate a tab replacement of 4 spaces, defaults to 4 spaces by + tab character + :param str label_direction: string defining the label text orientation. There are 5 + configurations possibles ``LTR``-Left-To-Right ``RTL``-Right-To-Left + ``UPD``-Upside Down ``UPR``-Upwards ``DWR``-Downwards. It defaults to ``LTR``""" + + # This maps label_direction to TileGrid's transpose_xy, flip_x, flip_y + _DIR_MAP = { + "UPR": (True, True, False), + "DWR": (True, False, True), + "UPD": (False, True, True), + "LTR": (False, False, False), + "RTL": (False, False, False), + } + + def __init__( + self, font: Union[BuiltinFont, BDF, PCF], save_text: bool = True, **kwargs + ) -> None: + + self._bitmap = None + self._tilegrid = None + self._prev_label_direction = None + + super().__init__(font, **kwargs) + + self._save_text = save_text + self._text = self._replace_tabs(self._text) + + # call the text updater with all the arguments. + self._reset_text( + font=font, + text=self._text, + line_spacing=self._line_spacing, + scale=self.scale, + ) + + def _reset_text( + self, + font: Optional[Union[BuiltinFont, BDF, PCF]] = None, + text: Optional[str] = None, + line_spacing: Optional[float] = None, + scale: Optional[int] = None, + ) -> None: + # pylint: disable=too-many-branches, too-many-statements, too-many-locals + + # Store all the instance variables + if font is not None: + self._font = font + if line_spacing is not None: + self._line_spacing = line_spacing + + # if text is not provided as a parameter (text is None), use the previous value. + if (text is None) and self._save_text: + text = self._text + + if self._save_text: # text string will be saved + self._text = self._replace_tabs(text) + else: + self._text = None # save a None value since text string is not saved + + # Check for empty string + if (text == "") or ( + text is None + ): # If empty string, just create a zero-sized bounding box and that's it. + + self._bounding_box = ( + 0, + 0, + 0, # zero width with text == "" + 0, # zero height with text == "" + ) + # Clear out any items in the self._local_group Group, in case this is an + # update to the bitmap_label + for _ in self._local_group: + self._local_group.pop(0) + + # Free the bitmap and tilegrid since they are removed + self._bitmap = None + self._tilegrid = None + + else: # The text string is not empty, so create the Bitmap and TileGrid and + # append to the self Group + + # Calculate the text bounding box + + # Calculate both "tight" and "loose" bounding box dimensions to match label for + # anchor_position calculations + ( + box_x, + tight_box_y, + x_offset, + tight_y_offset, + loose_box_y, + loose_y_offset, + ) = self._text_bounding_box( + text, + self._font, + ) # calculate the box size for a tight and loose backgrounds + + if self._background_tight: + box_y = tight_box_y + y_offset = tight_y_offset + + else: # calculate the box size for a loose background + box_y = loose_box_y + y_offset = loose_y_offset + + # Calculate the background size including padding + box_x = box_x + self._padding_left + self._padding_right + box_y = box_y + self._padding_top + self._padding_bottom + + # Create the Bitmap unless it can be reused + new_bitmap = None + if ( + self._bitmap is None + or self._bitmap.width != box_x + or self._bitmap.height != box_y + ): + new_bitmap = displayio.Bitmap(box_x, box_y, len(self._palette)) + self._bitmap = new_bitmap + else: + self._bitmap.fill(0) + + # Place the text into the Bitmap + self._place_text( + self._bitmap, + text if self._label_direction != "RTL" else "".join(reversed(text)), + self._font, + self._padding_left - x_offset, + self._padding_top + y_offset, + ) + + if self._base_alignment: + label_position_yoffset = 0 + else: + label_position_yoffset = self._ascent // 2 + + # Create the TileGrid if not created bitmap unchanged + if self._tilegrid is None or new_bitmap: + self._tilegrid = displayio.TileGrid( + self._bitmap, + pixel_shader=self._palette, + width=1, + height=1, + tile_width=box_x, + tile_height=box_y, + default_tile=0, + x=-self._padding_left + x_offset, + y=label_position_yoffset - y_offset - self._padding_top, + ) + # Clear out any items in the local_group Group, in case this is an update to + # the bitmap_label + for _ in self._local_group: + self._local_group.pop(0) + self._local_group.append( + self._tilegrid + ) # add the bitmap's tilegrid to the group + + # Set TileGrid properties based on label_direction + if self._label_direction != self._prev_label_direction: + tg1 = self._tilegrid + tg1.transpose_xy, tg1.flip_x, tg1.flip_y = self._DIR_MAP[ + self._label_direction + ] + + # Update bounding_box values. Note: To be consistent with label.py, + # this is the bounding box for the text only, not including the background. + if self._label_direction in ("UPR", "DWR"): + self._bounding_box = ( + self._tilegrid.x, + self._tilegrid.y, + tight_box_y, + box_x, + ) + else: + self._bounding_box = ( + self._tilegrid.x, + self._tilegrid.y, + box_x, + tight_box_y, + ) + + if ( + scale is not None + ): # Scale will be defined in local_group (Note: self should have scale=1) + self.scale = scale # call the setter + + # set the anchored_position with setter after bitmap is created, sets the + # x,y positions of the label + self.anchored_position = self._anchored_position + + @staticmethod + def _line_spacing_ypixels( + font: Union[BuiltinFont, BDF, PCF], line_spacing: float + ) -> int: + # Note: Scaling is provided at the Group level + return_value = int(line_spacing * font.get_bounding_box()[1]) + return return_value + + def _text_bounding_box( + self, text: str, font: Union[BuiltinFont, BDF, PCF] + ) -> Tuple[int, int, int, int, int, int]: + # pylint: disable=too-many-locals + + ascender_max, descender_max = self._ascent, self._descent + + lines = 1 + + xposition = ( + x_start + ) = yposition = y_start = 0 # starting x and y position (left margin) + + left = None + right = x_start + top = bottom = y_start + + y_offset_tight = self._ascent // 2 + + newline = False + line_spacing = self._line_spacing + + for char in text: + + if char == "\n": # newline + newline = True + + else: + + my_glyph = font.get_glyph(ord(char)) + + if my_glyph is None: # Error checking: no glyph found + print("Glyph not found: {}".format(repr(char))) + else: + if newline: + newline = False + xposition = x_start # reset to left column + yposition = yposition + self._line_spacing_ypixels( + font, line_spacing + ) # Add a newline + lines += 1 + if xposition == x_start: + if left is None: + left = my_glyph.dx + else: + left = min(left, my_glyph.dx) + xright = xposition + my_glyph.width + my_glyph.dx + xposition += my_glyph.shift_x + + right = max(right, xposition, xright) + + if yposition == y_start: # first line, find the Ascender height + top = min(top, -my_glyph.height - my_glyph.dy + y_offset_tight) + bottom = max(bottom, yposition - my_glyph.dy + y_offset_tight) + + if left is None: + left = 0 + + final_box_width = right - left + + final_box_height_tight = bottom - top + final_y_offset_tight = -top + y_offset_tight + + final_box_height_loose = (lines - 1) * self._line_spacing_ypixels( + font, line_spacing + ) + (ascender_max + descender_max) + final_y_offset_loose = ascender_max + + # return (final_box_width, final_box_height, left, final_y_offset) + + return ( + final_box_width, + final_box_height_tight, + left, + final_y_offset_tight, + final_box_height_loose, + final_y_offset_loose, + ) + + def _place_text( + self, + bitmap: displayio.Bitmap, + text: str, + font: Union[BuiltinFont, BDF, PCF], + xposition: int, + yposition: int, + skip_index: int = 0, # set to None to write all pixels, other wise skip this palette index + # when copying glyph bitmaps (this is important for slanted text + # where rectangular glyph boxes overlap) + ) -> Tuple[int, int, int, int]: + # pylint: disable=too-many-arguments, too-many-locals + + # placeText - Writes text into a bitmap at the specified location. + # + # Note: scale is pushed up to Group level + + x_start = xposition # starting x position (left margin) + y_start = yposition + + left = None + right = x_start + top = bottom = y_start + line_spacing = self._line_spacing + + for char in text: + + if char == "\n": # newline + xposition = x_start # reset to left column + yposition = yposition + self._line_spacing_ypixels( + font, line_spacing + ) # Add a newline + + else: + + my_glyph = font.get_glyph(ord(char)) + + if my_glyph is None: # Error checking: no glyph found + print("Glyph not found: {}".format(repr(char))) + else: + if xposition == x_start: + if left is None: + left = my_glyph.dx + else: + left = min(left, my_glyph.dx) + + right = max( + right, + xposition + my_glyph.shift_x, + xposition + my_glyph.width + my_glyph.dx, + ) + if yposition == y_start: # first line, find the Ascender height + top = min(top, -my_glyph.height - my_glyph.dy) + bottom = max(bottom, yposition - my_glyph.dy) + + glyph_offset_x = ( + my_glyph.tile_index * my_glyph.width + ) # for type BuiltinFont, this creates the x-offset in the glyph bitmap. + # for BDF loaded fonts, this should equal 0 + + y_blit_target = yposition - my_glyph.height - my_glyph.dy + + # Clip glyph y-direction if outside the font ascent/descent metrics. + # Note: bitmap.blit will automatically clip the bottom of the glyph. + y_clip = 0 + if y_blit_target < 0: + y_clip = -y_blit_target # clip this amount from top of bitmap + y_blit_target = 0 # draw the clipped bitmap at y=0 + + print( + 'Warning: Glyph clipped, exceeds Ascent property: "{}"'.format( + char + ) + ) + + if (y_blit_target + my_glyph.height) > bitmap.height: + print( + 'Warning: Glyph clipped, exceeds descent property: "{}"'.format( + char + ) + ) + + self._blit( + bitmap, + xposition + my_glyph.dx, + y_blit_target, + my_glyph.bitmap, + x_1=glyph_offset_x, + y_1=y_clip, + x_2=glyph_offset_x + my_glyph.width, + y_2=my_glyph.height, + skip_index=skip_index, # do not copy over any 0 background pixels + ) + + xposition = xposition + my_glyph.shift_x + + # bounding_box + return left, top, right - left, bottom - top + + def _blit( + self, + bitmap: displayio.Bitmap, # target bitmap + x: int, # target x upper left corner + y: int, # target y upper left corner + source_bitmap: displayio.Bitmap, # source bitmap + x_1: int = 0, # source x start + y_1: int = 0, # source y start + x_2: int = None, # source x end + y_2: int = None, # source y end + skip_index: int = None, # palette index that will not be copied + # (for example: the background color of a glyph) + ) -> None: + # pylint: disable=no-self-use, too-many-arguments + + if hasattr(bitmap, "blit"): # if bitmap has a built-in blit function, call it + # this function should perform its own input checks + bitmap.blit( + x, + y, + source_bitmap, + x1=x_1, + y1=y_1, + x2=x_2, + y2=y_2, + skip_index=skip_index, + ) + + else: # perform pixel by pixel copy of the bitmap + + # Perform input checks + + if x_2 is None: + x_2 = source_bitmap.width + if y_2 is None: + y_2 = source_bitmap.height + + # Rearrange so that x_1 < x_2 and y1 < y2 + if x_1 > x_2: + x_1, x_2 = x_2, x_1 + if y_1 > y_2: + y_1, y_2 = y_2, y_1 + + # Ensure that x2 and y2 are within source bitmap size + x_2 = min(x_2, source_bitmap.width) + y_2 = min(y_2, source_bitmap.height) + + for y_count in range(y_2 - y_1): + for x_count in range(x_2 - x_1): + x_placement = x + x_count + y_placement = y + y_count + + if (bitmap.width > x_placement >= 0) and ( + bitmap.height > y_placement >= 0 + ): # ensure placement is within target bitmap + + # get the palette index from the source bitmap + this_pixel_color = source_bitmap[ + y_1 + + ( + y_count * source_bitmap.width + ) # Direct index into a bitmap array is speedier than [x,y] tuple + + x_1 + + x_count + ] + + if (skip_index is None) or (this_pixel_color != skip_index): + bitmap[ # Direct index into a bitmap array is speedier than [x,y] tuple + y_placement * bitmap.width + x_placement + ] = this_pixel_color + elif y_placement > bitmap.height: + break + + def _set_line_spacing(self, new_line_spacing: float) -> None: + if self._save_text: + self._reset_text(line_spacing=new_line_spacing, scale=self.scale) + else: + raise RuntimeError("line_spacing is immutable when save_text is False") + + def _set_font(self, new_font: Union[BuiltinFont, BDF, PCF]) -> None: + self._font = new_font + if self._save_text: + self._reset_text(font=new_font, scale=self.scale) + else: + raise RuntimeError("font is immutable when save_text is False") + + def _set_text(self, new_text: str, scale: int) -> None: + self._reset_text(text=self._replace_tabs(new_text), scale=self.scale) + + def _set_background_color(self, new_color: Optional[int]): + self._background_color = new_color + if new_color is not None: + self._palette[0] = new_color + self._palette.make_opaque(0) + else: + self._palette[0] = 0 + self._palette.make_transparent(0) + + def _set_label_direction(self, new_label_direction: str) -> None: + self._prev_label_direction = self._label_direction + self._label_direction = new_label_direction + self._reset_text(text=str(self._text)) # Force a recalculation + + def _get_valid_label_directions(self) -> Tuple[str, ...]: + return "LTR", "RTL", "UPD", "UPR", "DWR" + + @property + def bitmap(self) -> displayio.Bitmap: + """ + The Bitmap object that the text and background are drawn into. + + :rtype: displayio.Bitmap + """ + return self._bitmap diff --git a/lib/adafruit_display_text/label.py b/lib/adafruit_display_text/label.py new file mode 100644 index 0000000..bac0649 --- /dev/null +++ b/lib/adafruit_display_text/label.py @@ -0,0 +1,427 @@ +# SPDX-FileCopyrightText: 2019 Scott Shawcroft for Adafruit Industries +# +# SPDX-License-Identifier: MIT + +""" +`adafruit_display_text.label` +==================================================== + +Displays text labels using CircuitPython's displayio. + +* Author(s): Scott Shawcroft + +Implementation Notes +-------------------- + +**Hardware:** + +**Software and Dependencies:** + +* Adafruit CircuitPython firmware for the supported boards: + https://circuitpython.org/downloads + +""" + +__version__ = "2.22.3" +__repo__ = "https://github.com/adafruit/Adafruit_CircuitPython_Display_Text.git" + + +try: + from typing import Union, Optional, Tuple + from fontio import BuiltinFont + from adafruit_bitmap_font.bdf import BDF + from adafruit_bitmap_font.pcf import PCF +except ImportError: + pass + +from displayio import Bitmap, Palette, TileGrid + +from adafruit_display_text import LabelBase + + +class Label(LabelBase): + # pylint: disable=too-many-instance-attributes + + """A label displaying a string of text. The origin point set by ``x`` and ``y`` + properties will be the left edge of the bounding box, and in the center of a M + glyph (if its one line), or the (number of lines * linespacing + M)/2. That is, + it will try to have it be center-left as close as possible. + + :param font: A font class that has ``get_bounding_box`` and ``get_glyph``. + Must include a capital M for measuring character size. + :type font: ~BuiltinFont, ~BDF, or ~PCF + :param str text: Text to display + :param int color: Color of all text in RGB hex + :param int background_color: Color of the background, use `None` for transparent + :param float line_spacing: Line spacing of text to display + :param bool background_tight: Set `True` only if you want background box to tightly + surround text. When set to 'True' Padding parameters will be ignored. + :param int padding_top: Additional pixels added to background bounding box at top. + This parameter could be negative indicating additional pixels subtracted from the + background bounding box. + :param int padding_bottom: Additional pixels added to background bounding box at bottom. + This parameter could be negative indicating additional pixels subtracted from the + background bounding box. + :param int padding_left: Additional pixels added to background bounding box at left. + This parameter could be negative indicating additional pixels subtracted from the + background bounding box. + :param int padding_right: Additional pixels added to background bounding box at right. + This parameter could be negative indicating additional pixels subtracted from the + background bounding box. + :param (float,float) anchor_point: Point that anchored_position moves relative to. + Tuple with decimal percentage of width and height. + (E.g. (0,0) is top left, (1.0, 0.5): is middle right.) + :param (int,int) anchored_position: Position relative to the anchor_point. Tuple + containing x,y pixel coordinates. + :param int scale: Integer value of the pixel scaling + :param bool base_alignment: when True allows to align text label to the baseline. + This is helpful when two or more labels need to be aligned to the same baseline + :param (int,str) tab_replacement: tuple with tab character replace information. When + (4, " ") will indicate a tab replacement of 4 spaces, defaults to 4 spaces by + tab character + :param str label_direction: string defining the label text orientation. There are 5 + configurations possibles ``LTR``-Left-To-Right ``RTL``-Right-To-Left + ``TTB``-Top-To-Bottom ``UPR``-Upwards ``DWR``-Downwards. It defaults to ``LTR``""" + + def __init__(self, font: Union[BuiltinFont, BDF, PCF], **kwargs) -> None: + self._background_palette = Palette(1) + self._added_background_tilegrid = False + + super().__init__(font, **kwargs) + + text = self._replace_tabs(self._text) + + self._width = len(text) + self._height = self._font.get_bounding_box()[1] + + # Create the two-color text palette + self._palette[0] = 0 + self._palette.make_transparent(0) + + if text is not None: + self._reset_text(str(text)) + + def _create_background_box(self, lines: int, y_offset: int) -> TileGrid: + """Private Class function to create a background_box + :param lines: int number of lines + :param y_offset: int y pixel bottom coordinate for the background_box""" + + left = self._bounding_box[0] + if self._background_tight: # draw a tight bounding box + box_width = self._bounding_box[2] + box_height = self._bounding_box[3] + x_box_offset = 0 + y_box_offset = self._bounding_box[1] + + else: # draw a "loose" bounding box to include any ascenders/descenders. + ascent, descent = self._ascent, self._descent + + if self._label_direction in ("UPR", "DWR", "TTB"): + box_height = ( + self._bounding_box[3] + self._padding_top + self._padding_bottom + ) + x_box_offset = -self._padding_bottom + box_width = ( + (ascent + descent) + + int((lines - 1) * self._width * self._line_spacing) + + self._padding_left + + self._padding_right + ) + else: + box_width = ( + self._bounding_box[2] + self._padding_left + self._padding_right + ) + x_box_offset = -self._padding_left + box_height = ( + (ascent + descent) + + int((lines - 1) * self._height * self._line_spacing) + + self._padding_top + + self._padding_bottom + ) + + if self._base_alignment: + y_box_offset = -ascent - self._padding_top + else: + y_box_offset = -ascent + y_offset - self._padding_top + + box_width = max(0, box_width) # remove any negative values + box_height = max(0, box_height) # remove any negative values + + if self._label_direction == "UPR": + movx = left + x_box_offset + movy = -box_height - x_box_offset + elif self._label_direction == "DWR": + movx = left + x_box_offset + movy = x_box_offset + elif self._label_direction == "TTB": + movx = left + x_box_offset + movy = x_box_offset + else: + movx = left + x_box_offset + movy = y_box_offset + + background_bitmap = Bitmap(box_width, box_height, 1) + tile_grid = TileGrid( + background_bitmap, + pixel_shader=self._background_palette, + x=movx, + y=movy, + ) + + return tile_grid + + def _set_background_color(self, new_color: Optional[int]) -> None: + """Private class function that allows updating the font box background color + + :param int new_color: Color as an RGB hex number, setting to None makes it transparent + """ + + if new_color is None: + self._background_palette.make_transparent(0) + if self._added_background_tilegrid: + self._local_group.pop(0) + self._added_background_tilegrid = False + else: + self._background_palette.make_opaque(0) + self._background_palette[0] = new_color + self._background_color = new_color + + lines = self._text.rstrip("\n").count("\n") + 1 + y_offset = self._ascent // 2 + + if self._bounding_box is None: + # Still in initialization + return + + if not self._added_background_tilegrid: # no bitmap is in the self Group + # add bitmap if text is present and bitmap sizes > 0 pixels + if ( + (len(self._text) > 0) + and ( + self._bounding_box[2] + self._padding_left + self._padding_right > 0 + ) + and ( + self._bounding_box[3] + self._padding_top + self._padding_bottom > 0 + ) + ): + self._local_group.insert( + 0, self._create_background_box(lines, y_offset) + ) + self._added_background_tilegrid = True + + else: # a bitmap is present in the self Group + # update bitmap if text is present and bitmap sizes > 0 pixels + if ( + (len(self._text) > 0) + and ( + self._bounding_box[2] + self._padding_left + self._padding_right > 0 + ) + and ( + self._bounding_box[3] + self._padding_top + self._padding_bottom > 0 + ) + ): + self._local_group[0] = self._create_background_box( + lines, self._y_offset + ) + else: # delete the existing bitmap + self._local_group.pop(0) + self._added_background_tilegrid = False + + def _update_text(self, new_text: str) -> None: + # pylint: disable=too-many-branches,too-many-statements + + x = 0 + y = 0 + if self._added_background_tilegrid: + i = 1 + else: + i = 0 + tilegrid_count = i + if self._base_alignment: + self._y_offset = 0 + else: + self._y_offset = self._ascent // 2 + + if self._label_direction == "RTL": + left = top = bottom = 0 + right = None + elif self._label_direction == "LTR": + right = top = bottom = 0 + left = None + else: + top = right = left = 0 + bottom = 0 + + for character in new_text: + if character == "\n": + y += int(self._height * self._line_spacing) + x = 0 + continue + glyph = self._font.get_glyph(ord(character)) + if not glyph: + continue + + position_x, position_y = 0, 0 + + if self._label_direction in ("LTR", "RTL"): + bottom = max(bottom, y - glyph.dy + self._y_offset) + if y == 0: # first line, find the Ascender height + top = min(top, -glyph.height - glyph.dy + self._y_offset) + position_y = y - glyph.height - glyph.dy + self._y_offset + + if self._label_direction == "LTR": + right = max(right, x + glyph.shift_x, x + glyph.width + glyph.dx) + if x == 0: + if left is None: + left = glyph.dx + else: + left = min(left, glyph.dx) + position_x = x + glyph.dx + else: + left = max( + left, abs(x) + glyph.shift_x, abs(x) + glyph.width + glyph.dx + ) + if x == 0: + if right is None: + right = glyph.dx + else: + right = max(right, glyph.dx) + position_x = x - glyph.width + + elif self._label_direction == "TTB": + if x == 0: + if left is None: + left = glyph.dx + else: + left = min(left, glyph.dx) + if y == 0: + top = min(top, -glyph.dy) + + bottom = max(bottom, y + glyph.height, y + glyph.height + glyph.dy) + right = max( + right, x + glyph.width + glyph.dx, x + glyph.shift_x + glyph.dx + ) + position_y = y + glyph.dy + position_x = x - glyph.width // 2 + self._y_offset + + elif self._label_direction == "UPR": + if x == 0: + if bottom is None: + bottom = -glyph.dx + + if y == 0: # first line, find the Ascender height + bottom = min(bottom, -glyph.dy) + left = min(left, x - glyph.height + self._y_offset) + top = min(top, y - glyph.width - glyph.dx, y - glyph.shift_x) + right = max(right, x + glyph.height, x + glyph.height - glyph.dy) + position_y = y - glyph.width - glyph.dx + position_x = x - glyph.height - glyph.dy + self._y_offset + + elif self._label_direction == "DWR": + if y == 0: + if top is None: + top = -glyph.dx + top = min(top, -glyph.dx) + if x == 0: + left = min(left, -glyph.dy) + left = min(left, x, x - glyph.dy - self._y_offset) + bottom = max(bottom, y + glyph.width + glyph.dx, y + glyph.shift_x) + right = max(right, x + glyph.height) + position_y = y + glyph.dx + position_x = x + glyph.dy - self._y_offset + + if glyph.width > 0 and glyph.height > 0: + face = TileGrid( + glyph.bitmap, + pixel_shader=self._palette, + default_tile=glyph.tile_index, + tile_width=glyph.width, + tile_height=glyph.height, + x=position_x, + y=position_y, + ) + + if self._label_direction == "UPR": + face.transpose_xy = True + face.flip_x = True + if self._label_direction == "DWR": + face.transpose_xy = True + face.flip_y = True + + if tilegrid_count < len(self._local_group): + self._local_group[tilegrid_count] = face + else: + self._local_group.append(face) + tilegrid_count += 1 + + if self._label_direction == "RTL": + x = x - glyph.shift_x + if self._label_direction == "TTB": + if glyph.height < 2: + y = y + glyph.shift_x + else: + y = y + glyph.height + 1 + if self._label_direction == "UPR": + y = y - glyph.shift_x + if self._label_direction == "DWR": + y = y + glyph.shift_x + if self._label_direction == "LTR": + x = x + glyph.shift_x + + i += 1 + + if self._label_direction == "LTR" and left is None: + left = 0 + if self._label_direction == "RTL" and right is None: + right = 0 + if self._label_direction == "TTB" and top is None: + top = 0 + + while len(self._local_group) > tilegrid_count: # i: + self._local_group.pop() + + if self._label_direction == "RTL": + # pylint: disable=invalid-unary-operand-type + # type-checkers think left can be None + self._bounding_box = (-left, top, left - right, bottom - top) + if self._label_direction == "TTB": + self._bounding_box = (left, top, right - left, bottom - top) + if self._label_direction == "UPR": + self._bounding_box = (left, top, right, bottom - top) + if self._label_direction == "DWR": + self._bounding_box = (left, top, right, bottom - top) + if self._label_direction == "LTR": + self._bounding_box = (left, top, right - left, bottom - top) + + self._text = new_text + + if self._background_color is not None: + self._set_background_color(self._background_color) + + def _reset_text(self, new_text: str) -> None: + current_anchored_position = self.anchored_position + self._update_text(str(self._replace_tabs(new_text))) + self.anchored_position = current_anchored_position + + def _set_font(self, new_font: Union[BuiltinFont, BDF, PCF]) -> None: + old_text = self._text + current_anchored_position = self.anchored_position + self._text = "" + self._font = new_font + self._height = self._font.get_bounding_box()[1] + self._update_text(str(old_text)) + self.anchored_position = current_anchored_position + + def _set_line_spacing(self, new_line_spacing: float) -> None: + self._line_spacing = new_line_spacing + self.text = self._text # redraw the box + + def _set_text(self, new_text: str, scale: int) -> None: + self._reset_text(new_text) + + def _set_label_direction(self, new_label_direction: str) -> None: + self._label_direction = new_label_direction + self._update_text(str(self._text)) + + def _get_valid_label_directions(self) -> Tuple[str, ...]: + return "LTR", "RTL", "UPR", "DWR", "TTB" diff --git a/lib/adafruit_display_text/scrolling_label.py b/lib/adafruit_display_text/scrolling_label.py new file mode 100644 index 0000000..be6310f --- /dev/null +++ b/lib/adafruit_display_text/scrolling_label.py @@ -0,0 +1,144 @@ +# SPDX-FileCopyrightText: 2019 Scott Shawcroft for Adafruit Industries +# +# SPDX-License-Identifier: MIT + +""" +`adafruit_display_text.scrolling_label` +==================================================== + +Displays text into a fixed-width label that scrolls leftward +if the full_text is large enough to need it. + +* Author(s): Tim Cocks + +Implementation Notes +-------------------- + +**Hardware:** + +**Software and Dependencies:** + +* Adafruit CircuitPython firmware for the supported boards: + https://circuitpython.org/downloads + +""" + +__version__ = "2.22.3" +__repo__ = "https://github.com/adafruit/Adafruit_CircuitPython_Display_Text.git" + +import time +from adafruit_display_text import bitmap_label + + +class ScrollingLabel(bitmap_label.Label): + + """ + ScrollingLabel - A fixed-width label that will scroll to the left + in order to show the full text if it's larger than the fixed-width. + + :param font: The font to use for the label. + :param max_characters: The number of characters that sets the fixed-width. Default is 10. + :param text: The full text to show in the label. If this is longer than + `max_characters` then the label will scroll to show everything. + :param animate_time: The number of seconds in between scrolling animation + frames. Default is 0.3 seconds. + :param current_index: The index of the first visible character in the label. + Default is 0, the first character. Will increase while scrolling. + """ + + # pylint: disable=too-many-arguments + def __init__( + self, + font, + max_characters=10, + text="", + animate_time=0.3, + current_index=0, + **kwargs + ): + + super().__init__(font, **kwargs) + self.animate_time = animate_time + self._current_index = current_index + self._last_animate_time = -1 + self.max_characters = max_characters + + if text[-1] != " ": + text = "{} ".format(text) + self._full_text = text + + self.update() + + def update(self, force=False): + """ + Attempt to update the display. If `animate_time` has elapsed since + previews animation frame then move the characters over by 1 index. + Must be called in the main loop of user code. + + :param force: whether to ignore `animation_time` and force the update. Default is False. + :return: None + """ + _now = time.monotonic() + if force or self._last_animate_time + self.animate_time <= _now: + + if len(self.full_text) <= self.max_characters: + self.text = self.full_text + self._last_animate_time = _now + return + + if self.current_index + self.max_characters <= len(self.full_text): + _showing_string = self.full_text[ + self.current_index : self.current_index + self.max_characters + ] + else: + _showing_string_start = self.full_text[self.current_index :] + _showing_string_end = "{}".format( + self.full_text[ + : (self.current_index + self.max_characters) + % len(self.full_text) + ] + ) + + _showing_string = "{}{}".format( + _showing_string_start, _showing_string_end + ) + self.text = _showing_string + + self.current_index += 1 + self._last_animate_time = _now + + return + + @property + def current_index(self): + """ + Index of the first visible character. + + :return int: the current index + """ + return self._current_index + + @current_index.setter + def current_index(self, new_index): + if new_index < len(self.full_text): + self._current_index = new_index + else: + self._current_index = new_index % len(self.full_text) + + @property + def full_text(self): + """ + The full text to be shown. If it's longer than `max_characters` then + scrolling will occur as needed. + + :return string: The full text of this label. + """ + return self._full_text + + @full_text.setter + def full_text(self, new_text): + if new_text[-1] != " ": + new_text = "{} ".format(new_text) + self._full_text = new_text + self.current_index = 0 + self.update() diff --git a/lib/adafruit_displayio_ssd1306.mpy b/lib/adafruit_displayio_ssd1306.mpy new file mode 100644 index 0000000..c916d48 Binary files /dev/null and b/lib/adafruit_displayio_ssd1306.mpy differ diff --git a/lib/adafruit_displayio_ssd1306.py b/lib/adafruit_displayio_ssd1306.py new file mode 100644 index 0000000..8e663ae --- /dev/null +++ b/lib/adafruit_displayio_ssd1306.py @@ -0,0 +1,117 @@ +# SPDX-FileCopyrightText: 2019 Scott Shawcroft for Adafruit Industries +# +# SPDX-License-Identifier: MIT + +""" +`adafruit_displayio_ssd1306` +================================================================================ + +DisplayIO driver for SSD1306 monochrome displays + + +* Author(s): Scott Shawcroft + +Implementation Notes +-------------------- + +**Hardware:** + +* `Monochrome 1.3" 128x64 OLED graphic display `_ +* `Monochrome 128x32 I2C OLED graphic display `_ +* `Monochrome 0.96" 128x64 OLED graphic display `_ +* `Monochrome 128x32 SPI OLED graphic display `_ +* `Adafruit FeatherWing OLED - 128x32 OLED `_ + +**Software and Dependencies:** + +* Adafruit CircuitPython (version 5+) firmware for the supported boards: + https://github.com/adafruit/circuitpython/releases + +""" + +import displayio + +__version__ = "0.0.0-auto.0" +__repo__ = "https://github.com/adafruit/Adafruit_CircuitPython_DisplayIO_SSD1306.git" + +# Sequence from page 19 here: https://cdn-shop.adafruit.com/datasheets/UG-2864HSWEG01+user+guide.pdf +_INIT_SEQUENCE = ( + b"\xAE\x00" # DISPLAY_OFF + b"\x20\x01\x00" # Set memory addressing to horizontal mode. + b"\x81\x01\xcf" # set contrast control + b"\xA1\x00" # Column 127 is segment 0 + b"\xA6\x00" # Normal display + b"\xc8\x00" # Normal display + b"\xA8\x01\x3f" # Mux ratio is 1/64 + b"\xd5\x01\x80" # Set divide ratio + b"\xd9\x01\xf1" # Set pre-charge period + b"\xda\x01\x12" # Set com configuration + b"\xdb\x01\x40" # Set vcom configuration + b"\x8d\x01\x14" # Enable charge pump + b"\xAF\x00" # DISPLAY_ON +) + + +class SSD1306(displayio.Display): + """ + SSD1306 driver + + :param int width: The width of the display + :param int height: The height of the display + :param int rotation: The rotation of the display in degrees. Default is 0. Must be one of + (0, 90, 180, 270) + """ + + def __init__(self, bus, **kwargs): + # Patch the init sequence for 32 pixel high displays. + init_sequence = bytearray(_INIT_SEQUENCE) + height = kwargs["height"] + if "rotation" in kwargs and kwargs["rotation"] % 180 != 0: + height = kwargs["width"] + init_sequence[16] = height - 1 # patch mux ratio + if kwargs["height"] == 32: + init_sequence[25] = 0x02 # patch com configuration + super().__init__( + bus, + init_sequence, + **kwargs, + color_depth=1, + grayscale=True, + pixels_in_byte_share_row=False, + set_column_command=0x21, + set_row_command=0x22, + data_as_commands=True, + brightness_command=0x81, + single_byte_bounds=True, + ) + self._is_awake = True # Display starts in active state (_INIT_SEQUENCE) + + @property + def is_awake(self): + """ + The power state of the display. (read-only) + + `True` if the display is active, `False` if in sleep mode. + + :type: bool + """ + return self._is_awake + + def sleep(self): + """ + Put display into sleep mode. + + Display uses < 10uA in sleep mode. Display remembers display data and operation mode + active prior to sleeping. MP can access (update) the built-in display RAM. + """ + if self._is_awake: + self.bus.send(0xAE, b"") # 0xAE = display off, sleep mode + self._is_awake = False + + def wake(self): + """ + Wake display from sleep mode + """ + if not self._is_awake: + self.bus.send(0xAF, b"") # 0xAF = display on + self._is_awake = True diff --git a/lib/adafruit_framebuf.mpy b/lib/adafruit_framebuf.mpy new file mode 100644 index 0000000..1174b01 Binary files /dev/null and b/lib/adafruit_framebuf.mpy differ diff --git a/lib/adafruit_pixelbuf.mpy b/lib/adafruit_pixelbuf.mpy new file mode 100644 index 0000000..0add534 Binary files /dev/null and b/lib/adafruit_pixelbuf.mpy differ diff --git a/lib/adafruit_ssd1306.mpy b/lib/adafruit_ssd1306.mpy new file mode 100644 index 0000000..e6816ca Binary files /dev/null and b/lib/adafruit_ssd1306.mpy differ diff --git a/lib/neopixel.mpy b/lib/neopixel.mpy new file mode 100644 index 0000000..7edc9b9 Binary files /dev/null and b/lib/neopixel.mpy differ diff --git a/main.py b/main.py new file mode 100644 index 0000000..235af31 --- /dev/null +++ b/main.py @@ -0,0 +1,86 @@ +print("Starting") + +import board + +from kmk.kmk_keyboard import KMKKeyboard +from kmk.keys import KC +from kmk.scanners import DiodeOrientation +from kmk.modules.encoder import EncoderHandler +from kmk.modules.oled2 import Oled +from kmk.extensions.lock_status import LockStatus +from kmk.extensions.RGB import RGB +from kmk.extensions.rgb import AnimationModes +from kmk.modules.power import Power +from kmk.modules.layers import Layers + +## Create KMK Keyboard object +keyboard = KMKKeyboard() +## + +## Add layer support +keyboard.modules.append(Layers()) +## + +## Add status indicator support +locks = LockStatus() +keyboard.extensions.append(locks) +## + + +## RGB extension config +rgb_ext = RGB(pixel_pin=board.A3, num_pixels=9, + hue_default=249, refresh_rate=60, + animation_speed=1, breathe_center=1, + knight_effect_length=3, + animation_mode=AnimationModes.STATIC) + +keyboard.extensions.append(rgb_ext) +## + +## Oled module configuration +oled_handler = Oled() +oled_handler.locks = locks +oled_handler.refresh_rate = 0.2 +oled_handler.pins = (board.D3, board.D2) + +keyboard.modules.append(oled_handler) +## + +## Encoder configuration +encoder_handler = EncoderHandler() +encoder_handler.pins = ((board.A2, board.A1, None, False),) +encoder_handler.map = [(( KC.VOLU, KC.VOLD, KC.C),), (( KC.RGB_HUI, KC.RGB_HUD, KC.C), )] + +keyboard.modules.append(encoder_handler) +## + +## Keyboard debug flag +keyboard.debug_enabled = True +## + +## Keyboard Matrix definition +keyboard.row_pins = (board.D5, board.D6, board.D7, board.D8, board.D9) +keyboard.col_pins = (board.SCK, board.MISO, board.MOSI, board.D10) +keyboard.diode_orientation = DiodeOrientation.COL2ROW +## + +## Define keymap for the board +#TODO: Move keymap to its own module +keyboard.keymap = [ + [KC.MO(1), KC.NUMPAD_SLASH, KC.NUMPAD_ASTERISK, KC.NUMPAD_MINUS, + KC.NUMPAD_7, KC.NUMPAD_8, KC.NUMPAD_9, KC.NUMPAD_PLUS, + KC.NUMPAD_4, KC.NUMPAD_5, KC.NUMPAD_6, KC.NO, + KC.NUMPAD_1, KC.NUMPAD_2, KC.NUMPAD_3, KC.NO, KC.NO, + KC.NUMPAD_0, KC.NUMPAD_DOT, KC.NUMPAD_ENTER], + [KC.NO, KC.NUM_LOCK, KC.NUMPAD_ASTERISK, KC.RGB_AND, + KC.RGB_MODE_PLAIN, KC.RGB_MODE_BREATHE, KC.RGB_MODE_RAINBOW, KC.RGB_ANI, + KC.NUMPAD_4, KC.NUMPAD_5, KC.NUMPAD_6, KC.NO, + KC.NUMPAD_1, KC.NUMPAD_2, KC.NUMPAD_3, KC.NO, KC.NO, + KC.NUMPAD_0, KC.NUMPAD_DOT, KC.RGB_MODE_RAINBOW] +] +## + +## Start the board +if __name__ == '__main__': + keyboard.go() +## \ No newline at end of file