From 69f2363278494b87f47d40342e3c9e2f5ea4ecfb Mon Sep 17 00:00:00 2001 From: KemoNine Date: Wed, 19 Apr 2023 08:30:30 -0400 Subject: [PATCH] initial repo setup which includes current release of kmk --- kmk/__init__.py | 0 kmk/consts.py | 8 + kmk/extensions/__init__.py | 54 ++ kmk/extensions/international.py | 59 ++ kmk/extensions/keymap_extras/keymap_jp.py | 34 + kmk/extensions/led.py | 259 ++++++ kmk/extensions/lock_status.py | 85 ++ kmk/extensions/media_keys.py | 57 ++ kmk/extensions/oled.py | 272 ++++++ kmk/extensions/peg_oled_display.py | 161 ++++ kmk/extensions/peg_rgb_matrix.py | 201 +++++ kmk/extensions/rgb.py | 599 +++++++++++++ kmk/extensions/statusled.py | 145 +++ kmk/extensions/stringy_keymaps.py | 45 + kmk/handlers/__init__.py | 0 kmk/handlers/sequences.py | 155 ++++ kmk/handlers/stock.py | 146 ++++ kmk/hid.py | 355 ++++++++ kmk/key_validators.py | 9 + kmk/keys.py | 825 ++++++++++++++++++ kmk/kmk_keyboard.py | 547 ++++++++++++ kmk/kmktime.py | 34 + kmk/modules/__init__.py | 46 + kmk/modules/adns9800.py | 227 +++++ kmk/modules/capsword.py | 99 +++ kmk/modules/cg_swap.py | 70 ++ kmk/modules/combos.py | 335 +++++++ kmk/modules/dynamic_sequences.py | 259 ++++++ kmk/modules/easypoint.py | 130 +++ kmk/modules/encoder.py | 312 +++++++ kmk/modules/holdtap.py | 266 ++++++ kmk/modules/layers.py | 184 ++++ kmk/modules/midi.py | 103 +++ kmk/modules/modtap.py | 14 + kmk/modules/mouse_keys.py | 180 ++++ kmk/modules/oneshot.py | 87 ++ kmk/modules/pimoroni_trackball.py | 314 +++++++ kmk/modules/potentiometer.py | 94 ++ kmk/modules/power.py | 149 ++++ kmk/modules/rapidfire.py | 103 +++ kmk/modules/serialace.py | 63 ++ kmk/modules/split.py | 384 ++++++++ kmk/modules/sticky_mod.py | 63 ++ kmk/modules/string_substitution.py | 222 +++++ kmk/modules/tapdance.py | 122 +++ kmk/quickpin/pro_micro/avr_promicro.py | 20 + kmk/quickpin/pro_micro/boardsource_blok.py | 28 + kmk/quickpin/pro_micro/helios.py | 28 + kmk/quickpin/pro_micro/kb2040.py | 28 + kmk/quickpin/pro_micro/nice_nano.py | 28 + .../pro_micro/sparkfun_promicro_rp2040.py | 28 + kmk/scanners/__init__.py | 42 + kmk/scanners/digitalio.py | 142 +++ kmk/scanners/encoder.py | 43 + kmk/scanners/keypad.py | 107 +++ kmk/scheduler.py | 67 ++ kmk/transports/__init__.py | 0 kmk/transports/pio_uart.py | 92 ++ kmk/types.py | 26 + kmk/utils.py | 39 + 60 files changed, 8594 insertions(+) create mode 100644 kmk/__init__.py create mode 100644 kmk/consts.py create mode 100644 kmk/extensions/__init__.py create mode 100644 kmk/extensions/international.py create mode 100644 kmk/extensions/keymap_extras/keymap_jp.py create mode 100644 kmk/extensions/led.py create mode 100644 kmk/extensions/lock_status.py create mode 100644 kmk/extensions/media_keys.py create mode 100644 kmk/extensions/oled.py create mode 100644 kmk/extensions/peg_oled_display.py create mode 100644 kmk/extensions/peg_rgb_matrix.py create mode 100644 kmk/extensions/rgb.py create mode 100644 kmk/extensions/statusled.py create mode 100644 kmk/extensions/stringy_keymaps.py create mode 100644 kmk/handlers/__init__.py create mode 100644 kmk/handlers/sequences.py create mode 100644 kmk/handlers/stock.py create mode 100644 kmk/hid.py create mode 100644 kmk/key_validators.py create mode 100644 kmk/keys.py create mode 100644 kmk/kmk_keyboard.py create mode 100644 kmk/kmktime.py create mode 100644 kmk/modules/__init__.py create mode 100644 kmk/modules/adns9800.py create mode 100644 kmk/modules/capsword.py create mode 100644 kmk/modules/cg_swap.py create mode 100644 kmk/modules/combos.py create mode 100644 kmk/modules/dynamic_sequences.py create mode 100644 kmk/modules/easypoint.py create mode 100644 kmk/modules/encoder.py create mode 100644 kmk/modules/holdtap.py create mode 100644 kmk/modules/layers.py create mode 100644 kmk/modules/midi.py create mode 100644 kmk/modules/modtap.py create mode 100644 kmk/modules/mouse_keys.py create mode 100644 kmk/modules/oneshot.py create mode 100644 kmk/modules/pimoroni_trackball.py create mode 100644 kmk/modules/potentiometer.py create mode 100644 kmk/modules/power.py create mode 100644 kmk/modules/rapidfire.py create mode 100644 kmk/modules/serialace.py create mode 100644 kmk/modules/split.py create mode 100644 kmk/modules/sticky_mod.py create mode 100644 kmk/modules/string_substitution.py create mode 100644 kmk/modules/tapdance.py create mode 100644 kmk/quickpin/pro_micro/avr_promicro.py create mode 100644 kmk/quickpin/pro_micro/boardsource_blok.py create mode 100644 kmk/quickpin/pro_micro/helios.py create mode 100644 kmk/quickpin/pro_micro/kb2040.py create mode 100644 kmk/quickpin/pro_micro/nice_nano.py create mode 100644 kmk/quickpin/pro_micro/sparkfun_promicro_rp2040.py create mode 100644 kmk/scanners/__init__.py create mode 100644 kmk/scanners/digitalio.py create mode 100644 kmk/scanners/encoder.py create mode 100644 kmk/scanners/keypad.py create mode 100644 kmk/scheduler.py create mode 100644 kmk/transports/__init__.py create mode 100644 kmk/transports/pio_uart.py create mode 100644 kmk/types.py create mode 100644 kmk/utils.py 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..164d02b --- /dev/null +++ b/kmk/consts.py @@ -0,0 +1,8 @@ +from micropython import const + + +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..33eea68 --- /dev/null +++ b/kmk/extensions/__init__.py @@ -0,0 +1,54 @@ +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 + + def deinit(self, keyboard): + pass 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..3f41706 --- /dev/null +++ b/kmk/extensions/led.py @@ -0,0 +1,259 @@ +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=50, + 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 = brightness + 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 f'LED({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): + self.animate() + + 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 standby to prevent cycles from being wasted + self.animation_mode = AnimationModes.STATIC_STANDBY + + 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.STATIC_STANDBY: + pass + 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 + + if self._enabled: + self.off() + 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..d081ddc --- /dev/null +++ b/kmk/extensions/lock_status.py @@ -0,0 +1,85 @@ +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 = None + self.hid = None + self._report_updated = False + for device in usb_hid.devices: + if device.usage == HIDUsage.KEYBOARD: + self.hid = device + + def __repr__(self): + return f'LockStatus(report={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 and report[0] != self.report: + self.report = report[0] + self._report_updated = True + else: + self._report_updated = False + else: + # _report_updated shouldn't ever be True if hid is + # falsy, but I would rather be safe than sorry. + self._report_updated = False + return + + def on_powersave_enable(self, sandbox): + return + + def on_powersave_disable(self, sandbox): + return + + @property + def report_updated(self): + return self._report_updated + + def check_state(self, lock_code): + # This is false if there's no valid report, or all report bits are zero + if self.report: + return bool(self.report & lock_code) + else: + # Just in case, default to False if we don't know anything + return False + + def get_num_lock(self): + return self.check_state(LockCode.NUMLOCK) + + def get_caps_lock(self): + return self.check_state(LockCode.CAPSLOCK) + + def get_scroll_lock(self): + return self.check_state(LockCode.SCROLLLOCK) + + def get_compose(self): + return self.check_state(LockCode.COMPOSE) + + def get_kana(self): + return self.check_state(LockCode.KANA) diff --git a/kmk/extensions/media_keys.py b/kmk/extensions/media_keys.py new file mode 100644 index 0000000..309a00a --- /dev/null +++ b/kmk/extensions/media_keys.py @@ -0,0 +1,57 @@ +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=111, names=('BRIGHTNESS_UP', 'BRIU')) # 0x6F + make_consumer_key(code=112, names=('BRIGHTNESS_DOWN', 'BRID')) # 0x70 + 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/oled.py b/kmk/extensions/oled.py new file mode 100644 index 0000000..eb67215 --- /dev/null +++ b/kmk/extensions/oled.py @@ -0,0 +1,272 @@ +import busio +from supervisor import ticks_ms + +import adafruit_displayio_ssd1306 +import displayio +import terminalio +from adafruit_display_text import label + +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, ticks_diff +from kmk.modules.split import Split, SplitSide +from kmk.utils import clamp + +displayio.release_displays() + + +class TextEntry: + def __init__( + self, + text='', + x=0, + y=0, + x_anchor='L', + y_anchor='T', + direction='LTR', + line_spacing=0.75, + inverted=False, + layer=None, + side=None, + ): + self.text = text + self.direction = direction + self.line_spacing = line_spacing + self.inverted = inverted + self.layer = layer + self.color = 0xFFFFFF + self.background_color = 0x000000 + self.x_anchor = 0.0 + self.y_anchor = 0.0 + if x_anchor == 'L': + self.x_anchor = 0.0 + x = x + 1 + if x_anchor == 'M': + self.x_anchor = 0.5 + if x_anchor == 'R': + self.x_anchor = 1.0 + if y_anchor == 'T': + self.y_anchor = 0.0 + if y_anchor == 'M': + self.y_anchor = 0.5 + if y_anchor == 'B': + self.y_anchor = 1.0 + self.anchor_point = (self.x_anchor, self.y_anchor) + self.anchored_position = (x, y) + if inverted: + self.color = 0x000000 + self.background_color = 0xFFFFFF + self.side = side + if side == 'L': + self.side = SplitSide.LEFT + if side == 'R': + self.side = SplitSide.RIGHT + + +class ImageEntry: + def __init__(self, x=0, y=0, image='', layer=None, side=None): + self.x = x + self.y = y + self.image = displayio.OnDiskBitmap(image) + self.layer = layer + self.side = side + if side == 'L': + self.side = SplitSide.LEFT + if side == 'R': + self.side = SplitSide.RIGHT + + +class Oled(Extension): + def __init__( + self, + i2c=None, + sda=None, + scl=None, + device_address=0x3C, + entries=[], + width=128, + height=32, + flip: bool = False, + flip_left: bool = False, + flip_right: bool = False, + brightness=0.8, + brightness_step=0.1, + dim_time=20, + dim_target=0.1, + off_time=60, + powersave_dim_time=10, + powersave_dim_target=0.1, + powersave_off_time=30, + ): + self.device_address = device_address + self.flip = flip + self.flip_left = flip_left + self.flip_right = flip_right + self.entries = entries + self.width = width + self.height = height + self.prev_layer = None + self.brightness = brightness + self.brightness_step = brightness_step + self.timer_start = ticks_ms() + self.powersave = False + self.dim_time_ms = dim_time * 1000 + self.dim_target = dim_target + self.off_time_ms = off_time * 1000 + self.powersavedim_time_ms = powersave_dim_time * 1000 + self.powersave_dim_target = powersave_dim_target + self.powersave_off_time_ms = powersave_off_time * 1000 + self.dim_period = PeriodicTimer(50) + self.split_side = None + # i2c initialization + self.i2c = i2c + if self.i2c is None: + self.i2c = busio.I2C(scl, sda) + + make_key( + names=('OLED_BRI',), + on_press=self.oled_brightness_increase, + on_release=handler_passthrough, + ) + make_key( + names=('OLED_BRD',), + on_press=self.oled_brightness_decrease, + on_release=handler_passthrough, + ) + + def render(self, layer): + splash = displayio.Group() + + for entry in self.entries: + if entry.layer != layer and entry.layer is not None: + continue + if isinstance(entry, TextEntry): + splash.append( + label.Label( + terminalio.FONT, + text=entry.text, + color=entry.color, + background_color=entry.background_color, + anchor_point=entry.anchor_point, + anchored_position=entry.anchored_position, + label_direction=entry.direction, + line_spacing=entry.line_spacing, + padding_left=1, + ) + ) + elif isinstance(entry, ImageEntry): + splash.append( + displayio.TileGrid( + entry.image, + pixel_shader=entry.image.pixel_shader, + x=entry.x, + y=entry.y, + ) + ) + self.display.show(splash) + + def on_runtime_enable(self, sandbox): + return + + def on_runtime_disable(self, sandbox): + return + + def during_bootup(self, keyboard): + + for module in keyboard.modules: + if isinstance(module, Split): + self.split_side = module.split_side + + if self.split_side == SplitSide.LEFT: + self.flip = self.flip_left + elif self.split_side == SplitSide.RIGHT: + self.flip = self.flip_right + + for idx, entry in enumerate(self.entries): + if entry.side != self.split_side and entry.side is not None: + del self.entries[idx] + + self.display = adafruit_displayio_ssd1306.SSD1306( + displayio.I2CDisplay(self.i2c, device_address=self.device_address), + width=self.width, + height=self.height, + rotation=180 if self.flip else 0, + brightness=self.brightness, + ) + + def before_matrix_scan(self, sandbox): + if self.dim_period.tick(): + self.dim() + if sandbox.active_layers[0] != self.prev_layer: + self.prev_layer = sandbox.active_layers[0] + self.render(sandbox.active_layers[0]) + + def after_matrix_scan(self, sandbox): + if sandbox.matrix_update or sandbox.secondary_matrix_update: + self.timer_start = ticks_ms() + + def before_hid_send(self, sandbox): + return + + def after_hid_send(self, sandbox): + return + + def on_powersave_enable(self, sandbox): + self.powersave = True + + def on_powersave_disable(self, sandbox): + self.powersave = False + + def deinit(self, sandbox): + displayio.release_displays() + self.i2c.deinit() + + def oled_brightness_increase(self): + self.display.brightness = clamp( + self.display.brightness + self.brightness_step, 0, 1 + ) + self.brightness = self.display.brightness # Save current brightness + + def oled_brightness_decrease(self): + self.display.brightness = clamp( + self.display.brightness - self.brightness_step, 0, 1 + ) + self.brightness = self.display.brightness # Save current brightness + + def dim(self): + if self.powersave: + + if ( + self.powersave_off_time_ms + and ticks_diff(ticks_ms(), self.timer_start) + > self.powersave_off_time_ms + ): + self.display.sleep() + + elif ( + self.powersave_dim_time_ms + and ticks_diff(ticks_ms(), self.timer_start) + > self.powersave_dim_time_ms + ): + self.display.brightness = self.powersave_dim_target + + else: + self.display.brightness = self.brightness + self.display.wake() + + elif ( + self.off_time_ms + and ticks_diff(ticks_ms(), self.timer_start) > self.off_time_ms + ): + self.display.sleep() + + elif ( + self.dim_time_ms + and ticks_diff(ticks_ms(), self.timer_start) > self.dim_time_ms + ): + self.display.brightness = self.dim_target + + else: + self.display.brightness = self.brightness + self.display.wake() diff --git a/kmk/extensions/peg_oled_display.py b/kmk/extensions/peg_oled_display.py new file mode 100644 index 0000000..897b291 --- /dev/null +++ b/kmk/extensions/peg_oled_display.py @@ -0,0 +1,161 @@ +import busio +import gc + +import adafruit_displayio_ssd1306 +import displayio +import terminalio +from adafruit_display_text import label + +from kmk.extensions import Extension + + +class OledDisplayMode: + TXT = 0 + IMG = 1 + + +class OledReactionType: + STATIC = 0 + LAYER = 1 + + +class OledData: + def __init__( + self, + image=None, + corner_one=None, + corner_two=None, + corner_three=None, + corner_four=None, + ): + if image: + self.data = [image] + elif corner_one and corner_two and corner_three and corner_four: + self.data = [corner_one, corner_two, corner_three, corner_four] + + +class Oled(Extension): + def __init__( + self, + views, + toDisplay=OledDisplayMode.TXT, + oWidth=128, + oHeight=32, + flip: bool = False, + ): + displayio.release_displays() + self.rotation = 180 if flip else 0 + self._views = views.data + self._toDisplay = toDisplay + self._width = oWidth + self._height = oHeight + self._prevLayers = 0 + gc.collect() + + def returnCurrectRenderText(self, layer, singleView): + # for now we only have static things and react to layers. But when we react to battery % and wpm we can handle the logic here + if singleView[0] == OledReactionType.STATIC: + return singleView[1][0] + if singleView[0] == OledReactionType.LAYER: + return singleView[1][layer] + + def renderOledTextLayer(self, layer): + splash = displayio.Group() + splash.append( + label.Label( + terminalio.FONT, + text=self.returnCurrectRenderText(layer, self._views[0]), + color=0xFFFFFF, + x=0, + y=10, + ) + ) + splash.append( + label.Label( + terminalio.FONT, + text=self.returnCurrectRenderText(layer, self._views[1]), + color=0xFFFFFF, + x=64, + y=10, + ) + ) + splash.append( + label.Label( + terminalio.FONT, + text=self.returnCurrectRenderText(layer, self._views[2]), + color=0xFFFFFF, + x=0, + y=25, + ) + ) + splash.append( + label.Label( + terminalio.FONT, + text=self.returnCurrectRenderText(layer, self._views[3]), + color=0xFFFFFF, + x=64, + y=25, + ) + ) + self._display.show(splash) + gc.collect() + + def renderOledImgLayer(self, layer): + splash = displayio.Group() + odb = displayio.OnDiskBitmap( + '/' + self.returnCurrectRenderText(layer, self._views[0]) + ) + image = displayio.TileGrid(odb, pixel_shader=odb.pixel_shader) + splash.append(image) + self._display.show(splash) + gc.collect() + + def updateOLED(self, sandbox): + if self._toDisplay == OledDisplayMode.TXT: + self.renderOledTextLayer(sandbox.active_layers[0]) + if self._toDisplay == OledDisplayMode.IMG: + self.renderOledImgLayer(sandbox.active_layers[0]) + gc.collect() + + def on_runtime_enable(self, sandbox): + return + + def on_runtime_disable(self, sandbox): + return + + def during_bootup(self, keyboard): + displayio.release_displays() + i2c = busio.I2C(keyboard.SCL, keyboard.SDA) + self._display = adafruit_displayio_ssd1306.SSD1306( + displayio.I2CDisplay(i2c, device_address=0x3C), + width=self._width, + height=self._height, + rotation=self.rotation, + ) + if self._toDisplay == OledDisplayMode.TXT: + self.renderOledTextLayer(0) + if self._toDisplay == OledDisplayMode.IMG: + self.renderOledImgLayer(0) + return + + def before_matrix_scan(self, sandbox): + if sandbox.active_layers[0] != self._prevLayers: + self._prevLayers = sandbox.active_layers[0] + self.updateOLED(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/peg_rgb_matrix.py b/kmk/extensions/peg_rgb_matrix.py new file mode 100644 index 0000000..77a2825 --- /dev/null +++ b/kmk/extensions/peg_rgb_matrix.py @@ -0,0 +1,201 @@ +import neopixel + +from storage import getmount + +from kmk.extensions import Extension +from kmk.handlers.stock import passthrough as handler_passthrough +from kmk.keys import make_key + + +class Color: + OFF = [0, 0, 0] + BLACK = OFF + WHITE = [249, 249, 249] + RED = [255, 0, 0] + AZURE = [153, 245, 255] + BLUE = [0, 0, 255] + CYAN = [0, 255, 255] + GREEN = [0, 255, 0] + YELLOW = [255, 247, 0] + MAGENTA = [255, 0, 255] + ORANGE = [255, 77, 0] + PURPLE = [255, 0, 242] + TEAL = [0, 128, 128] + PINK = [255, 0, 255] + + +class Rgb_matrix_data: + def __init__(self, keys=[], underglow=[]): + if len(keys) == 0: + print('No colors passed for your keys') + return + if len(underglow) == 0: + print('No colors passed for your underglow') + return + self.data = keys + underglow + + @staticmethod + def generate_led_map( + number_of_keys, number_of_underglow, key_color, underglow_color + ): + keys = [key_color] * number_of_keys + underglow = [underglow_color] * number_of_underglow + print(f'Rgb_matrix_data(keys={keys},\nunderglow={underglow})') + + +class Rgb_matrix(Extension): + def __init__( + self, + rgb_order=(1, 0, 2), # GRB WS2812 + disable_auto_write=False, + ledDisplay=[], + split=False, + rightSide=False, + ): + name = str(getmount('/').label) + self.rgb_order = rgb_order + self.disable_auto_write = disable_auto_write + self.split = split + self.rightSide = rightSide + self.brightness_step = 0.1 + self.brightness = 0 + + if name.endswith('L'): + self.rightSide = False + elif name.endswith('R'): + self.rightSide = True + if type(ledDisplay) is Rgb_matrix_data: + self.ledDisplay = ledDisplay.data + else: + self.ledDisplay = ledDisplay + + make_key( + names=('RGB_TOG',), on_press=self._rgb_tog, on_release=handler_passthrough + ) + make_key( + names=('RGB_BRI',), on_press=self._rgb_bri, on_release=handler_passthrough + ) + make_key( + names=('RGB_BRD',), on_press=self._rgb_brd, on_release=handler_passthrough + ) + + def _rgb_tog(self, *args, **kwargs): + if self.enable: + self.off() + else: + self.on() + self.enable = not self.enable + + def _rgb_bri(self, *args, **kwargs): + self.increase_brightness() + + def _rgb_brd(self, *args, **kwargs): + self.decrease_brightness() + + def on(self): + if self.neopixel: + self.setBasedOffDisplay() + self.neopixel.show() + + def off(self): + if self.neopixel: + self.set_rgb_fill((0, 0, 0)) + + def set_rgb_fill(self, rgb): + if self.neopixel: + self.neopixel.fill(rgb) + if self.disable_auto_write: + self.neopixel.show() + + def set_brightness(self, brightness=None): + if brightness is None: + brightness = self.brightness + + if self.neopixel: + self.neopixel.brightness = brightness + if self.disable_auto_write: + self.neopixel.show() + + def increase_brightness(self, step=None): + if step is None: + step = self.brightness_step + + self.brightness = ( + self.brightness + step if self.brightness + step <= 1.0 else 1.0 + ) + + self.set_brightness(self.brightness) + + def decrease_brightness(self, step=None): + if step is None: + step = self.brightness_step + + self.brightness = ( + self.brightness - step if self.brightness - step >= 0.0 else 0.0 + ) + self.set_brightness(self.brightness) + + def setBasedOffDisplay(self): + if self.split: + for i, val in enumerate(self.ledDisplay): + if self.rightSide: + if self.keyPos[i] >= (self.num_pixels / 2): + self.neopixel[int(self.keyPos[i] - (self.num_pixels / 2))] = ( + val[0], + val[1], + val[2], + ) + else: + if self.keyPos[i] <= (self.num_pixels / 2): + self.neopixel[self.keyPos[i]] = (val[0], val[1], val[2]) + else: + for i, val in enumerate(self.ledDisplay): + self.neopixel[self.keyPos[i]] = (val[0], val[1], val[2]) + + def on_runtime_enable(self, sandbox): + return + + def on_runtime_disable(self, sandbox): + return + + def during_bootup(self, board): + self.neopixel = neopixel.NeoPixel( + board.rgb_pixel_pin, + board.num_pixels, + brightness=board.brightness_limit, + pixel_order=self.rgb_order, + auto_write=not self.disable_auto_write, + ) + self.num_pixels = board.num_pixels + self.keyPos = board.led_key_pos + self.brightness = board.brightness_limit + self.on() + 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): + if self.neopixel: + self.neopixel.brightness = ( + self.neopixel.brightness / 2 + if self.neopixel.brightness / 2 > 0 + else 0.1 + ) + if self.disable_auto_write: + self.neopixel.show() + + def on_powersave_disable(self, sandbox): + if self.neopixel: + self.neopixel.brightness = self.brightness + if self.disable_auto_write: + self.neopixel.show() diff --git a/kmk/extensions/rgb.py b/kmk/extensions/rgb.py new file mode 100644 index 0000000..80add18 --- /dev/null +++ b/kmk/extensions/rgb.py @@ -0,0 +1,599 @@ +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.scheduler import create_task +from kmk.utils import Debug, clamp + +debug = Debug(__name__) + +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(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, + rgb_order=(1, 0, 2), # GRB WS2812 + val_limit=255, + hue_default=0, + sat_default=255, + 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, + ): + self.pixel_pin = pixel_pin + self.num_pixels = num_pixels + self.rgb_order = rgb_order + 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.pixels = pixels + self.refresh_rate = refresh_rate + + self.rgbw = bool(len(rgb_order) == 4) + + 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): + if self.pixels is None: + import neopixel + + self.pixels = neopixel.NeoPixel( + self.pixel_pin, + self.num_pixels, + pixel_order=self.rgb_order, + auto_write=not self.disable_auto_write, + ) + + # 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) + + if debug.enabled: + for n, pixels in enumerate(self.pixels): + debug(f'pixels[{n}] = {pixels.__class__}[{len(pixels)}]') + + self._task = create_task(self.animate, period_ms=(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): + pass + + def on_powersave_enable(self, sandbox): + return + + def on_powersave_disable(self, sandbox): + self._do_update() + + def deinit(self, sandbox): + for pixel in self.pixels: + pixel.deinit() + + 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 + ''' + + val = clamp(val, 0, self.val_limit) + + 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: + ''' + + val = clamp(val, 0, self.val_limit) + + 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): + pixels[index] = rgb + break + index -= len(pixels) + + 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: + 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..fa5e793 --- /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 f'SLED({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/extensions/stringy_keymaps.py b/kmk/extensions/stringy_keymaps.py new file mode 100644 index 0000000..a2b1ef7 --- /dev/null +++ b/kmk/extensions/stringy_keymaps.py @@ -0,0 +1,45 @@ +from kmk.extensions import Extension +from kmk.keys import KC + + +class StringyKeymaps(Extension): + ##### + # User-configurable + debug_enabled = False + + def on_runtime_enable(self, keyboard): + return + + def on_runtime_disable(self, keyboard): + return + + def during_bootup(self, keyboard): + for _, layer in enumerate(keyboard.keymap): + for key_idx, key in enumerate(layer): + if isinstance(key, str): + replacement = KC.get(key) + if replacement is None: + replacement = KC.NO + if self.debug_enabled: + print(f"Failed replacing '{key}'. Using KC.NO") + elif self.debug_enabled: + print(f"Replacing '{key}' with {replacement}") + layer[key_idx] = replacement + + 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 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..89bdc33 --- /dev/null +++ b/kmk/handlers/stock.py @@ -0,0 +1,146 @@ +from time import sleep + + +def passthrough(key, keyboard, *args, **kwargs): + return keyboard + + +def default_pressed(key, keyboard, KC, coord_int=None, *args, **kwargs): + keyboard.hid_pending = True + + keyboard.keys_pressed.add(key) + + return keyboard + + +def default_released(key, keyboard, KC, coord_int=None, *args, **kwargs): # NOQA + keyboard.hid_pending = True + keyboard.keys_pressed.discard(key) + + return keyboard + + +def reset(*args, **kwargs): + import microcontroller + + microcontroller.reset() + + +def reload(*args, **kwargs): + import supervisor + + supervisor.reload() + + +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 + + +def ble_disconnect(key, keyboard, *args, **kwargs): + from kmk.hid import HIDModes + + if keyboard.hid_type != HIDModes.BLE: + return keyboard + + keyboard._hid_helper.clear_bonds() + return keyboard + + +def any_pressed(key, keyboard, *args, **kwargs): + from random import randint + + key.code = randint(4, 56) + default_pressed(key, keyboard, *args, **kwargs) diff --git a/kmk/hid.py b/kmk/hid.py new file mode 100644 index 0000000..77c47d9 --- /dev/null +++ b/kmk/hid.py @@ -0,0 +1,355 @@ +import supervisor +import usb_hid +from micropython import const + +from storage import getmount + +from kmk.keys import FIRST_KMK_INTERNAL_KEY, ConsumerKey, ModifierKey, MouseKey +from kmk.utils import clamp + +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._cc_report = bytearray(HID_REPORT_SIZES[HIDReportTypes.CONSUMER] + 1) + self._cc_report[0] = HIDReportTypes.CONSUMER + self._cc_pending = False + + self._pd_report = bytearray(HID_REPORT_SIZES[HIDReportTypes.MOUSE] + 1) + self._pd_report[0] = HIDReportTypes.MOUSE + self._pd_pending = False + + self.post_init() + + def __repr__(self): + return f'{self.__class__.__name__}(REPORT_BYTES={self.REPORT_BYTES})' + + def post_init(self): + pass + + def create_report(self, keys_pressed, axes): + self.clear_all() + + for key in keys_pressed: + if key.code >= FIRST_KMK_INTERNAL_KEY: + continue + + if isinstance(key, ModifierKey): + self.add_modifier(key) + elif isinstance(key, ConsumerKey): + self.add_cc(key) + elif isinstance(key, MouseKey): + self.add_pd(key) + else: + self.add_key(key) + if key.has_modifiers: + for mod in key.has_modifiers: + self.add_modifier(mod) + + for axis in axes: + self.move_axis(axis) + + 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) + + if self._cc_pending: + self.hid_send(self._cc_report) + self._cc_pending = False + + if self._pd_pending: + self.hid_send(self._pd_report) + self._pd_pending = False + + return self + + def clear_all(self): + for idx, _ in enumerate(self.report_keys): + self.report_keys[idx] = 0x00 + + self.remove_cc() + self.remove_pd() + self.clear_axis() + + 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 + + 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 + + for idx, _ in enumerate(where_to_place): + if where_to_place[idx] == key.code: + where_to_place[idx] = 0x00 + + return self + + def add_cc(self, cc): + # Add (or write over) consumer control report. There can only be one CC + # active at any time. + memoryview(self._cc_report)[1:3] = cc.code.to_bytes(2, 'little') + self._cc_pending = True + + def remove_cc(self): + # Remove consumer control report. + report = memoryview(self._cc_report)[1:3] + if report != b'\x00\x00': + report[:] = b'\x00\x00' + self._cc_pending = True + + def add_pd(self, key): + self._pd_report[1] |= key.code + self._pd_pending = True + + def remove_pd(self): + if self._pd_report[1]: + self._pd_pending = True + self._pd_report[1] = 0x00 + + def move_axis(self, axis): + delta = clamp(axis.delta, -127, 127) + axis.delta -= delta + self._pd_report[axis.code + 2] = 0xFF & delta + self._pd_pending = True + + def clear_axis(self): + for idx in range(2, len(self._pd_report)): + self._pd_report[idx] = 0x00 + + +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): + if not supervisor.runtime.usb_connected: + return + + # 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..676f221 --- /dev/null +++ b/kmk/key_validators.py @@ -0,0 +1,9 @@ +from kmk.types import KeySeqSleepMeta, UnicodeModeKeyMeta + + +def key_seq_sleep_validator(ms): + return KeySeqSleepMeta(ms) + + +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..039d977 --- /dev/null +++ b/kmk/keys.py @@ -0,0 +1,825 @@ +try: + from typing import Callable, Optional, Tuple +except ImportError: + pass + +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 +from kmk.utils import Debug + +# Type aliases / forward declaration; can't use the proper types because of circular imports. +Keyboard = object +Key = object + + +class KeyType: + SIMPLE = const(0) + MODIFIER = const(1) + CONSUMER = const(2) + MOUSE = const(3) + + +FIRST_KMK_INTERNAL_KEY = const(1000) +NEXT_AVAILABLE_KEY = 1000 + +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) + +debug = Debug(__name__) + + +class Axis: + def __init__(self, code: int) -> None: + self.code = code + self.delta = 0 + + def __repr__(self) -> str: + return f'Axis(code={self.code}, delta={self.delta})' + + def move(self, keyboard: Keyboard, delta: int): + self.delta += delta + if self.delta: + keyboard.axes.add(self) + keyboard.hid_pending = True + else: + keyboard.axes.discard(self) + + +class AX: + W = Axis(2) + X = Axis(0) + Y = Axis(1) + + +def maybe_make_key( + code: Optional[int], + names: Tuple[str, ...], + *args, + **kwargs, +) -> Callable[[str], Key]: + def closure(candidate): + if candidate in names: + return make_key(code=code, names=names, *args, **kwargs) + + return closure + + +def maybe_make_argumented_key( + validator=lambda *validator_args, **validator_kwargs: object(), + names: Tuple[str, ...] = tuple(), # NOQA + *constructor_args, + **constructor_kwargs, +) -> Callable[[str], Key]: + def closure(candidate): + if candidate in names: + return make_argumented_key( + validator, names, *constructor_args, **constructor_kwargs + ) + + return closure + + +def maybe_make_no_key(candidate: str) -> Optional[Key]: + # 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 + keys = ( + ('NO', 'XXXXXXX'), + ('TRANSPARENT', 'TRNS'), + ) + + for names in keys: + if candidate in names: + return make_key( + names=names, + on_press=handlers.passthrough, + on_release=handlers.passthrough, + ) + + +def maybe_make_alpha_key(candidate: str) -> Optional[Key]: + if len(candidate) != 1: + return + + candidate_upper = candidate.upper() + if candidate_upper in ALL_ALPHAS: + return make_key( + code=4 + ALL_ALPHAS.index(candidate_upper), + names=(candidate_upper, candidate.lower()), + ) + + +def maybe_make_numeric_key(candidate: str) -> Optional[Key]: + if candidate in ALL_NUMBERS or candidate in ALL_NUMBER_ALIASES: + try: + offset = ALL_NUMBERS.index(candidate) + except ValueError: + offset = ALL_NUMBER_ALIASES.index(candidate) + + return make_key( + code=30 + offset, + names=(ALL_NUMBERS[offset], ALL_NUMBER_ALIASES[offset]), + ) + + +def maybe_make_mod_key(candidate: str) -> Optional[Key]: + # MEH = LCTL | LALT | LSFT + # HYPR = LCTL | LALT | LSFT | LGUI + mods = ( + (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')), + (0x07, ('MEH',)), + (0x0F, ('HYPER', 'HYPR')), + ) + + for code, names in mods: + if candidate in names: + return make_key(code=code, names=names, type=KeyType.MODIFIER) + + +def maybe_make_more_ascii(candidate: str) -> Optional[Key]: + codes = ( + (40, ('ENTER', 'ENT', '\n')), + (41, ('ESCAPE', 'ESC')), + (42, ('BACKSPACE', 'BSPACE', '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', '/')), + ) + + for code, names in codes: + if candidate in names: + return make_key(code=code, names=names) + + +def maybe_make_fn_key(candidate: str) -> Optional[Key]: + codes = ( + (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',)), + ) + + for code, names in codes: + if candidate in names: + return make_key(code=code, names=names) + + +def maybe_make_navlock_key(candidate: str) -> Optional[Key]: + codes = ( + (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',)), + ) + + for code, names in codes: + if candidate in names: + return make_key(code=code, names=names) + + +def maybe_make_numpad_key(candidate: str) -> Optional[Key]: + codes = ( + (83, ('NUM_LOCK', 'NUMLOCK', 'NLCK')), + (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')), + ) + + for code, names in codes: + if candidate in names: + return make_key(code=code, names=names) + + +def maybe_make_shifted_key(candidate: str) -> Optional[Key]: + codes = ( + (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', '?')), + ) + + for code, names in codes: + if candidate in names: + return make_key(code=code, names=names, has_modifiers={KC.LSFT.code}) + + +def maybe_make_international_key(candidate: str) -> Optional[Key]: + codes = ( + (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',)), + ) + + for code, names in codes: + if candidate in names: + return make_key(code=code, names=names) + + +def maybe_make_unicode_key(candidate: str) -> Optional[Key]: + keys = ( + ( + ('UC_MODE_NOOP', 'UC_DISABLE'), + handlers.uc_mode_pressed, + UnicodeModeKeyMeta(UnicodeMode.NOOP), + ), + ( + ('UC_MODE_LINUX', 'UC_MODE_IBUS'), + handlers.uc_mode_pressed, + UnicodeModeKeyMeta(UnicodeMode.IBUS), + ), + ( + ('UC_MODE_MACOS', 'UC_MODE_OSX', 'US_MODE_RALT'), + handlers.uc_mode_pressed, + UnicodeModeKeyMeta(UnicodeMode.RALT), + ), + ( + ('UC_MODE_WINC',), + handlers.uc_mode_pressed, + UnicodeModeKeyMeta(UnicodeMode.WINC), + ), + ) + + for names, handler, meta in keys: + if candidate in names: + return make_key(names=names, on_press=handler, meta=meta) + + if candidate in ('UC_MODE',): + return make_argumented_key( + names=('UC_MODE',), + validator=unicode_mode_key_validator, + on_press=handlers.uc_mode_pressed, + ) + + +def maybe_make_firmware_key(candidate: str) -> Optional[Key]: + keys = ( + ((('BLE_REFRESH',), handlers.ble_refresh)), + ((('BLE_DISCONNECT',), handlers.ble_disconnect)), + ((('BOOTLOADER',), handlers.bootloader)), + ((('DEBUG', 'DBG'), handlers.debug_pressed)), + ((('HID_SWITCH', 'HID'), handlers.hid_switch)), + ((('RELOAD', 'RLD'), handlers.reload)), + ((('RESET',), handlers.reset)), + ((('ANY',), handlers.any_pressed)), + ) + + for names, handler in keys: + if candidate in names: + return make_key(names=names, on_press=handler) + + +KEY_GENERATORS = ( + maybe_make_no_key, + maybe_make_alpha_key, + maybe_make_numeric_key, + maybe_make_firmware_key, + maybe_make_key( + None, + ('BKDL',), + on_press=handlers.bkdl_pressed, + on_release=handlers.bkdl_released, + ), + maybe_make_key( + None, + ('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. + maybe_make_argumented_key( + key_seq_sleep_validator, + ('MACRO_SLEEP_MS', 'SLEEP_IN_SEQ'), + on_press=handlers.sleep_pressed, + ), + maybe_make_mod_key, + # More ASCII standard keys + maybe_make_more_ascii, + # Function Keys + maybe_make_fn_key, + # Lock Keys, Navigation, etc. + maybe_make_navlock_key, + # Numpad + # FIXME: Investigate whether this key actually works, and + # uncomment when/if it does. + # maybe_make_key(131, ('LOCKING_NUM', 'LNUM')), + maybe_make_numpad_key, + # 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. + maybe_make_shifted_key, + # International + maybe_make_international_key, + maybe_make_unicode_key, +) + + +class KeyAttrDict: + # Instead of relying on the uncontrollable availability of a big chunk of + # contiguous memory for key caching, we can manually fragment the cache into + # reasonably small partitions. The partition size is chosen from the magic + # values of CPs hash allocation sizes. + # (https://github.com/adafruit/circuitpython/blob/main/py/map.c, 2023-02) + __partition_size = 37 + __cache = [{}] + + def __iter__(self): + for partition in self.__cache: + for name in partition: + yield name + + def __setitem__(self, name: str, key: Key): + # Overwrite existing reference. + for partition in self.__cache: + if name in partition: + partition[name] = key + return key + + # Insert new reference. + if len(self.__cache[-1]) >= self.__partition_size: + self.__cache.append({}) + self.__cache[-1][name] = key + return key + + def __getattr__(self, name: str): + return self.__getitem__(name) + + def get(self, name: str, default: Optional[Key] = None): + try: + return self.__getitem__(name) + except Exception: + return default + + def clear(self): + self.__cache.clear() + self.__cache.append({}) + + def __getitem__(self, name: str): + for partition in self.__cache: + if name in partition: + return partition[name] + + for func in KEY_GENERATORS: + maybe_key = func(name) + if maybe_key: + break + + if not maybe_key: + if debug.enabled: + debug(f'Invalid key: {name}') + return KC.NO + + if debug.enabled: + debug(f'{name}: {maybe_key}') + + return maybe_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: int, + has_modifiers: Optional[list[Key, ...]] = None, + no_press: bool = False, + no_release: bool = False, + on_press: Callable[ + [object, Key, Keyboard, ...], None + ] = handlers.default_pressed, + on_release: Callable[ + [object, Key, Keyboard, ...], None + ] = handlers.default_released, + meta: object = 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: Optional[bool] = None, no_release: Optional[bool] = None + ) -> Key: + 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 f'Key(code={self.code}, has_modifiers={self.has_modifiers})' + + def on_press(self, keyboard: Keyboard, coord_int: Optional[int] = None) -> None: + if hasattr(self, '_pre_press_handlers'): + for fn in self._pre_press_handlers: + if not fn(self, keyboard, KC, coord_int): + return + + self._handle_press(self, keyboard, KC, coord_int) + + if hasattr(self, '_post_press_handlers'): + for fn in self._post_press_handlers: + fn(self, keyboard, KC, coord_int) + + def on_release(self, keyboard: Keyboard, coord_int: Optional[int] = None) -> None: + if hasattr(self, '_pre_release_handlers'): + for fn in self._pre_release_handlers: + if not fn(self, keyboard, KC, coord_int): + return + + self._handle_release(self, keyboard, KC, coord_int) + + if hasattr(self, '_post_release_handlers'): + for fn in self._post_release_handlers: + fn(self, keyboard, KC, coord_int) + + def clone(self) -> Key: + ''' + 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: Callable[[Key, Keyboard, ...], bool]) -> None: + ''' + 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) + + 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) + + def after_press_handler(self, fn: Callable[[Key, Keyboard, ...], bool]) -> None: + ''' + 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) + + 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) + + def before_release_handler(self, fn: Callable[[Key, Keyboard, ...], bool]) -> None: + ''' + 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) + + 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) + + def after_release_handler(self, fn: Callable[[Key, Keyboard, ...], bool]) -> None: + ''' + 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) + + 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) + + +class ModifierKey(Key): + FAKE_CODE = const(-1) + + def __call__( + self, + modified_key: Optional[Key] = None, + no_press: Optional[bool] = None, + no_release: Optional[bool] = None, + ) -> Key: + 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 f'ModifierKey(code={self.code}, has_modifiers={self.has_modifiers})' + + +class ConsumerKey(Key): + pass + + +class MouseKey(Key): + pass + + +def make_key( + code: Optional[int] = None, + names: Tuple[str, ...] = tuple(), # NOQA + type: KeyType = KeyType.SIMPLE, + **kwargs, +) -> Key: + ''' + 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 == KeyType.SIMPLE: + constructor = Key + elif type == KeyType.MODIFIER: + constructor = ModifierKey + elif type == KeyType.CONSUMER: + constructor = ConsumerKey + elif type == KeyType.MOUSE: + constructor = MouseKey + 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 + + return key + + +def make_mod_key(code: int, names: Tuple[str, ...], *args, **kwargs) -> Key: + return make_key(code, names, *args, **kwargs, type=KeyType.MODIFIER) + + +def make_shifted_key(code: int, names: Tuple[str, ...]) -> Key: + return make_key(code, names, has_modifiers={KC.LSFT.code}) + + +def make_consumer_key(*args, **kwargs) -> Key: + return make_key(*args, **kwargs, type=KeyType.CONSUMER) + + +def make_mouse_key(*args, **kwargs) -> Key: + return make_key(*args, **kwargs, type=KeyType.MOUSE) + + +# Argumented keys are implicitly internal, so auto-gen of code +# is almost certainly the best plan here +def make_argumented_key( + validator: object = lambda *validator_args, **validator_kwargs: object(), + names: Tuple[str, ...] = tuple(), # NOQA + *constructor_args, + **constructor_kwargs, +) -> Key: + global NEXT_AVAILABLE_KEY + + def _argumented_key(*user_args, **user_kwargs) -> Key: + 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..63cb672 --- /dev/null +++ b/kmk/kmk_keyboard.py @@ -0,0 +1,547 @@ +try: + from typing import Callable, Optional +except ImportError: + pass + +from collections import namedtuple +from keypad import Event as KeyEvent + +from kmk.consts import UnicodeMode +from kmk.hid import BLEHID, USBHID, AbstractHID, HIDModes +from kmk.keys import KC, Key +from kmk.modules import Module +from kmk.scanners.keypad import MatrixScanner +from kmk.scheduler import Task, cancel_task, create_task, get_due_task +from kmk.utils import Debug + +debug = Debug('kmk.keyboard') + +KeyBufferFrame = namedtuple( + 'KeyBufferFrame', ('key', 'is_pressed', 'int_coord', 'index') +) + + +def debug_error(module, message: str, error: Exception): + if debug.enabled: + debug( + message, ': ', error.__class__.__name__, ': ', error, name=module.__module__ + ) + + +class Sandbox: + matrix_update = None + secondary_matrix_update = None + active_layers = None + + +class KMKKeyboard: + ##### + # User-configurable + keymap = [] + coord_mapping = None + + row_pins = None + col_pins = None + diode_orientation = None + matrix = None + + unicode_mode = UnicodeMode.NOOP + + modules = [] + extensions = [] + sandbox = Sandbox() + + ##### + # Internal State + keys_pressed = set() + axes = 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 = [] + _trigger_powersave_enable = False + _trigger_powersave_disable = False + i2c_deinit_count = 0 + _go_args = None + _processing_timeouts = False + _resume_buffer = [] + _resume_buffer_x = [] + + # 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 = {} + + def __repr__(self) -> str: + return self.__class__.__name__ + + def _send_hid(self) -> None: + if not self._hid_send_enabled: + return + + if debug.enabled: + if self.keys_pressed: + debug('keys_pressed=', self.keys_pressed) + if self.axes: + debug('axes=', self.axes) + + self._hid_helper.create_report(self.keys_pressed, self.axes) + try: + self._hid_helper.send() + except Exception as err: + debug_error(self._hid_helper, 'send', err) + + self.hid_pending = False + + for axis in self.axes: + axis.move(self, 0) + + def _handle_matrix_report(self, kevent: KeyEvent) -> None: + if kevent is not None: + self._on_matrix_changed(kevent) + + def _find_key_in_map(self, int_coord: int) -> Key: + try: + idx = self.coord_mapping.index(int_coord) + except ValueError: + if debug.enabled: + debug('no such int_coord: ', int_coord) + + return None + + for layer in self.active_layers: + try: + key = self.keymap[layer][idx] + except IndexError: + key = None + if debug.enabled: + debug('keymap IndexError: idx=', idx, ' layer=', layer) + + if not key or key == KC.TRNS: + continue + + return key + + def _on_matrix_changed(self, kevent: KeyEvent) -> None: + int_coord = kevent.key_number + is_pressed = kevent.pressed + + key = None + if not is_pressed: + try: + key = self._coordkeys_pressed[int_coord] + except KeyError: + if debug.enabled: + debug('release w/o press: ', int_coord) + + if key is None: + key = self._find_key_in_map(int_coord) + + if key is None: + return + + if debug.enabled: + debug(kevent, ': ', key) + + self.pre_process_key(key, is_pressed, int_coord) + + def _process_resume_buffer(self): + ''' + Resume the processing of buffered, delayed, deferred, etc. key events + emitted by modules. + + We use a copy of the `_resume_buffer` as a working buffer. The working + buffer holds all key events in the correct order for processing. If + during processing new events are pushed to the `_resume_buffer`, they + are prepended to the working buffer (which may not be emptied), in + order to preserve key event order. + We also double-buffer `_resume_buffer` with `_resume_buffer_x`, only + copying the reference to hopefully safe some time on allocations. + ''' + + buffer, self._resume_buffer = self._resume_buffer, self._resume_buffer_x + + while buffer: + ksf = buffer.pop(0) + key = ksf.key + + # Handle any unaccounted-for layer shifts by looking up the key resolution again. + if ksf.int_coord is not None: + key = self._find_key_in_map(ksf.int_coord) + + # Resume the processing of the key event and update the HID report + # when applicable. + self.pre_process_key(key, ksf.is_pressed, ksf.int_coord, ksf.index) + + if self.hid_pending: + self._send_hid() + self.hid_pending = False + + # Any newly buffered key events must be prepended to the working + # buffer. + if self._resume_buffer: + self._resume_buffer.extend(buffer) + buffer.clear() + buffer, self._resume_buffer = self._resume_buffer, buffer + + self._resume_buffer_x = buffer + + @property + def debug_enabled(self) -> bool: + return debug.enabled + + @debug_enabled.setter + def debug_enabled(self, enabled: bool): + debug.enabled = enabled + + def pre_process_key( + self, + key: Key, + is_pressed: bool, + int_coord: Optional[int] = None, + index: int = 0, + ) -> None: + for module in self.modules[index:]: + try: + key = module.process_key(self, key, is_pressed, int_coord) + if key is None: + break + except Exception as err: + debug_error(module, 'process_key', err) + + if int_coord is not None: + if is_pressed: + self._coordkeys_pressed[int_coord] = key + else: + try: + del self._coordkeys_pressed[int_coord] + except KeyError: + if debug.enabled: + debug('release w/o press:', int_coord) + if debug.enabled: + debug('coordkeys_pressed=', self._coordkeys_pressed) + + if key: + self.process_key(key, is_pressed, int_coord) + + def process_key( + self, key: Key, is_pressed: bool, int_coord: Optional[int] = None + ) -> None: + if is_pressed: + key.on_press(self, int_coord) + else: + key.on_release(self, int_coord) + + def resume_process_key( + self, + module: Module, + key: Key, + is_pressed: bool, + int_coord: Optional[int] = None, + reprocess: Optional[bool] = False, + ) -> None: + index = self.modules.index(module) + (0 if reprocess else 1) + ksf = KeyBufferFrame( + key=key, is_pressed=is_pressed, int_coord=int_coord, index=index + ) + self._resume_buffer.append(ksf) + + def remove_key(self, keycode: Key) -> None: + self.keys_pressed.discard(keycode) + self.process_key(keycode, False) + + def add_key(self, keycode: Key) -> None: + self.keys_pressed.add(keycode) + self.process_key(keycode, True) + + def tap_key(self, keycode: Key) -> None: + self.add_key(keycode) + # On the next cycle, we'll remove the key. + self.set_timeout(0, lambda: self.remove_key(keycode)) + + def set_timeout(self, after_ticks: int, callback: Callable[[None], None]) -> [Task]: + return create_task(callback, after_ms=after_ticks) + + def cancel_timeout(self, timeout_key: int) -> None: + cancel_task(timeout_key) + + def _process_timeouts(self) -> None: + for task in get_due_task(): + task() + + def _init_sanity_check(self) -> None: + ''' + 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' + + def _init_coord_mapping(self) -> None: + ''' + 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) -> None: + 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 + + if debug.enabled: + debug('hid=', self._hid_helper) + + def _deinit_hid(self) -> None: + self._hid_helper.clear_all() + self._hid_helper.send() + + def _init_matrix(self) -> None: + if self.matrix is None: + self.matrix = MatrixScanner( + column_pins=self.col_pins, + row_pins=self.row_pins, + columns_to_anodes=self.diode_orientation, + ) + + 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,) + + if debug.enabled: + debug('matrix=', [_.__class__.__name__ for _ in self.matrix]) + + def during_bootup(self) -> None: + # Modules and extensions that fail `during_bootup` get removed from + # their respective lists. This serves as a self-check mechanism; any + # modules or extensions that initialize peripherals or data structures + # should do that in `during_bootup`. + for idx, module in enumerate(self.modules): + try: + module.during_bootup(self) + except Exception as err: + debug_error(module, 'during_bootup', err) + del self.modules[idx] + + if debug.enabled: + debug('modules=', [_.__class__.__name__ for _ in self.modules]) + + for idx, ext in enumerate(self.extensions): + try: + ext.during_bootup(self) + except Exception as err: + debug_error(ext, 'during_bootup', err) + del self.extensions[idx] + + if debug.enabled: + debug('extensions=', [_.__class__.__name__ for _ in self.extensions]) + + def before_matrix_scan(self) -> None: + for module in self.modules: + try: + module.before_matrix_scan(self) + except Exception as err: + debug_error(module, 'before_matrix_scan', err) + + for ext in self.extensions: + try: + ext.before_matrix_scan(self.sandbox) + except Exception as err: + debug_error(ext, 'before_matrix_scan', err) + + def after_matrix_scan(self) -> None: + for module in self.modules: + try: + module.after_matrix_scan(self) + except Exception as err: + debug_error(module, 'after_matrix_scan', err) + + for ext in self.extensions: + try: + ext.after_matrix_scan(self.sandbox) + except Exception as err: + debug_error(ext, 'after_matrix_scan', err) + + def before_hid_send(self) -> None: + for module in self.modules: + try: + module.before_hid_send(self) + except Exception as err: + debug_error(module, 'before_hid_send', err) + + for ext in self.extensions: + try: + ext.before_hid_send(self.sandbox) + except Exception as err: + debug_error(ext, 'before_hid_send', err) + + def after_hid_send(self) -> None: + for module in self.modules: + try: + module.after_hid_send(self) + except Exception as err: + debug_error(module, 'after_hid_send', err) + + for ext in self.extensions: + try: + ext.after_hid_send(self.sandbox) + except Exception as err: + debug_error(ext, 'after_hid_send', err) + + def powersave_enable(self) -> None: + for module in self.modules: + try: + module.on_powersave_enable(self) + except Exception as err: + debug_error(module, 'powersave_enable', err) + + for ext in self.extensions: + try: + ext.on_powersave_enable(self.sandbox) + except Exception as err: + debug_error(ext, 'powersave_enable', err) + + def powersave_disable(self) -> None: + for module in self.modules: + try: + module.on_powersave_disable(self) + except Exception as err: + debug_error(module, 'powersave_disable', err) + + for ext in self.extensions: + try: + ext.on_powersave_disable(self.sandbox) + except Exception as err: + debug_error(ext, 'powersave_disable', err) + + def deinit(self) -> None: + for module in self.modules: + try: + module.deinit(self) + except Exception as err: + debug_error(module, 'deinit', err) + + for ext in self.extensions: + try: + ext.deinit(self.sandbox) + except Exception as err: + debug_error(ext, 'deinit', err) + + def go(self, hid_type=HIDModes.USB, secondary_hid_type=None, **kwargs) -> None: + self._init(hid_type=hid_type, secondary_hid_type=secondary_hid_type, **kwargs) + try: + while True: + self._main_loop() + finally: + debug('Unexpected error: cleaning up') + self._deinit_hid() + self.deinit() + + def _init( + self, + hid_type: HIDModes = HIDModes.USB, + secondary_hid_type: Optional[HIDModes] = None, + **kwargs, + ) -> None: + self._go_args = kwargs + self.hid_type = hid_type + self.secondary_hid_type = secondary_hid_type + + if debug.enabled: + debug('Initialising ', self) + debug('unicode_mode=', self.unicode_mode) + + self._init_hid() + self._init_matrix() + self._init_coord_mapping() + self.during_bootup() + + if debug.enabled: + import gc + + gc.collect() + debug('mem_info used:', gc.mem_alloc(), ' free:', gc.mem_free()) + + def _main_loop(self) -> None: + self.sandbox.active_layers = self.active_layers.copy() + + self.before_matrix_scan() + + self._process_resume_buffer() + + for matrix in self.matrix: + update = matrix.scan_for_changes() + if update: + self.matrix_update = update + break + self.sandbox.matrix_update = self.matrix_update + 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._process_timeouts() + + 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() diff --git a/kmk/kmktime.py b/kmk/kmktime.py new file mode 100644 index 0000000..7476c43 --- /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: int, start: int) -> int: + diff = (new - start) & _TICKS_MAX + diff = ((diff + _TICKS_HALFPERIOD) & _TICKS_MAX) - _TICKS_HALFPERIOD + return diff + + +def ticks_add(ticks: int, delta: int) -> int: + return (ticks + delta) % _TICKS_PERIOD + + +def check_deadline(new: int, start: int, ms: int) -> int: + return ticks_diff(new, start) < ms + + +class PeriodicTimer: + def __init__(self, period: int): + self.period = period + self.last_tick = ticks_ms() + + def tick(self) -> bool: + 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..dd587c3 --- /dev/null +++ b/kmk/modules/__init__.py @@ -0,0 +1,46 @@ +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 + + def deinit(self, keyboard): + pass diff --git a/kmk/modules/adns9800.py b/kmk/modules/adns9800.py new file mode 100644 index 0000000..f7c5f7d --- /dev/null +++ b/kmk/modules/adns9800.py @@ -0,0 +1,227 @@ +import busio +import digitalio +import microcontroller + +import time + +from kmk.keys import AX +from kmk.modules import Module +from kmk.modules.adns9800_firmware import firmware + + +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.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: + AX.X.move(delta_x) + + if delta_y: + AX.Y.move(delta_y) + + if keyboard.debug_enabled: + print('Delta: ', delta_x, ' ', delta_y) + + 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..8c94a08 --- /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 + 1) + self._numbers = range(KC.N1.code, KC.N0.code + 1) + 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..b012d2a --- /dev/null +++ b/kmk/modules/combos.py @@ -0,0 +1,335 @@ +try: + from typing import Optional, Tuple, Union +except ImportError: + pass +from micropython import const + +import kmk.handlers.stock as handlers +from kmk.keys import Key, make_key +from kmk.kmk_keyboard import KMKKeyboard +from kmk.modules import Module +from kmk.utils import Debug + +debug = Debug(__name__) + + +class _ComboState: + RESET = const(0) + MATCHING = const(1) + ACTIVE = const(2) + IDLE = const(3) + + +class Combo: + fast_reset = False + per_key_timeout = False + timeout = 50 + _remaining = [] + _timeout = None + _state = _ComboState.IDLE + _match_coord = False + + def __init__( + self, + match: Tuple[Union[Key, int], ...], + result: Key, + fast_reset=None, + per_key_timeout=None, + timeout=None, + match_coord=None, + ): + ''' + match: tuple of keys (KC.A, KC.B) + result: key KC.C + ''' + self.match = match + self.result = result + if fast_reset is not None: + self.fast_reset = fast_reset + if per_key_timeout is not None: + self.per_key_timeout = per_key_timeout + if timeout is not None: + self.timeout = timeout + if match_coord is not None: + self._match_coord = match_coord + + def __repr__(self): + return f'{self.__class__.__name__}({[k.code for k in self.match]})' + + def matches(self, key: Key, int_coord: int): + raise NotImplementedError + + def has_match(self, key: Key, int_coord: int): + return self._match_coord and int_coord in self.match or key in self.match + + def insert(self, key: Key, int_coord: int): + if self._match_coord: + self._remaining.insert(0, int_coord) + else: + self._remaining.insert(0, key) + + def reset(self): + self._remaining = list(self.match) + + +class Chord(Combo): + def matches(self, key: Key, int_coord: int): + if not self._match_coord and key in self._remaining: + self._remaining.remove(key) + return True + elif self._match_coord and int_coord in self._remaining: + self._remaining.remove(int_coord) + return True + else: + return False + + +class Sequence(Combo): + fast_reset = True + per_key_timeout = True + timeout = 1000 + + def matches(self, key: Key, int_coord: int): + if ( + not self._match_coord and self._remaining and self._remaining[0] == key + ) or ( + self._match_coord and self._remaining and self._remaining[0] == int_coord + ): + self._remaining.pop(0) + return True + else: + return False + + +class Combos(Module): + def __init__(self, combos=[]): + self.combos = combos + self._key_buffer = [] + + make_key( + names=('LEADER', 'LDR'), + 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: 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: KMKKeyboard, key: Key, int_coord: Optional[int]): + # refill potential matches from timed-out matches + if self.count_matching() == 0: + for combo in self.combos: + if combo._state == _ComboState.RESET: + combo._state = _ComboState.MATCHING + + # filter potential matches + for combo in self.combos: + if combo._state != _ComboState.MATCHING: + continue + if combo.matches(key, int_coord): + continue + combo._state = _ComboState.IDLE + if combo._timeout: + keyboard.cancel_timeout(combo._timeout) + combo._timeout = keyboard.set_timeout( + combo.timeout, lambda c=combo: self.reset_combo(keyboard, c) + ) + + match_count = self.count_matching() + + if match_count: + # At least one combo matches current key: append key to buffer. + self._key_buffer.append((int_coord, key, True)) + key = None + + for first_match in self.combos: + if first_match._state == _ComboState.MATCHING: + break + + # Single match left: don't wait on timeout to activate + if match_count == 1 and not any(first_match._remaining): + combo = first_match + self.activate(keyboard, combo) + if combo._timeout: + keyboard.cancel_timeout(combo._timeout) + combo._timeout = None + self._key_buffer = [] + self.reset(keyboard) + + # Start or reset individual combo timeouts. + for combo in self.combos: + if combo._state != _ComboState.MATCHING: + continue + 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 + if self._key_buffer: + self._key_buffer.append((int_coord, key, True)) + self.send_key_buffer(keyboard) + self._key_buffer = [] + key = None + + return key + + def on_release(self, keyboard: KMKKeyboard, key: Key, int_coord: Optional[int]): + for combo in self.combos: + if combo._state != _ComboState.ACTIVE: + continue + if combo.has_match(key, int_coord): + # Deactivate combo if it matches current key. + self.deactivate(keyboard, combo) + + if combo.fast_reset: + self.reset_combo(keyboard, combo) + self._key_buffer = [] + else: + combo.insert(key, int_coord) + combo._state = _ComboState.MATCHING + + key = None + break + + else: + # Non-active but matching combos can either activate on key release + # if they're the only match, or "un-match" the released key but stay + # matching if they're a repeatable combo. + for combo in self.combos: + if combo._state != _ComboState.MATCHING: + continue + if not combo.has_match(key, int_coord): + continue + + # Combo matches, but first key released before timeout. + elif not any(combo._remaining) and self.count_matching() == 1: + keyboard.cancel_timeout(combo._timeout) + self.activate(keyboard, combo) + self._key_buffer = [] + keyboard._send_hid() + self.deactivate(keyboard, combo) + if combo.fast_reset: + self.reset_combo(keyboard, combo) + else: + combo.insert(key, int_coord) + combo._state = _ComboState.MATCHING + self.reset(keyboard) + + elif not any(combo._remaining): + continue + + # Skip combos that allow tapping. + elif combo.fast_reset: + continue + + # This was the last key released of a repeatable combo. + elif len(combo._remaining) == len(combo.match) - 1: + self.reset_combo(keyboard, combo) + if not self.count_matching(): + self._key_buffer.append((int_coord, key, False)) + self.send_key_buffer(keyboard) + self._key_buffer = [] + key = None + + # Anything between first and last key released. + else: + combo.insert(key, int_coord) + + # Don't propagate key-release events for keys that have been + # buffered. Append release events only if corresponding press is in + # buffer. + 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 + + # Reset on non-combo key up + if not self.count_matching(): + self.reset(keyboard) + + 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 + + if not any(combo._remaining): + self.activate(keyboard, combo) + # check if the last buffered key event was a 'release' + if not self._key_buffer[-1][2]: + keyboard._send_hid() + self.deactivate(keyboard, combo) + self._key_buffer = [] + self.reset(keyboard) + else: + if self.count_matching() == 1: + # 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: + keyboard.resume_process_key(self, key, is_pressed, int_coord) + + def activate(self, keyboard, combo): + if debug.enabled: + debug('activate', combo) + combo.result.on_press(keyboard) + combo._state = _ComboState.ACTIVE + + def deactivate(self, keyboard, combo): + if debug.enabled: + debug('deactivate', combo) + combo.result.on_release(keyboard) + combo._state = _ComboState.IDLE + + def reset_combo(self, keyboard, combo): + combo.reset() + if combo._timeout is not None: + keyboard.cancel_timeout(combo._timeout) + combo._timeout = None + combo._state = _ComboState.RESET + + def reset(self, keyboard): + for combo in self.combos: + if combo._state != _ComboState.ACTIVE: + self.reset_combo(keyboard, combo) + + def count_matching(self): + match_count = 0 + for combo in self.combos: + if combo._state == _ComboState.MATCHING: + match_count += 1 + return match_count diff --git a/kmk/modules/dynamic_sequences.py b/kmk/modules/dynamic_sequences.py new file mode 100644 index 0000000..e633da5 --- /dev/null +++ b/kmk/modules/dynamic_sequences.py @@ -0,0 +1,259 @@ +from micropython import const +from supervisor import ticks_ms + +from collections import namedtuple + +from kmk.keys import KC, make_argumented_key +from kmk.kmktime import check_deadline, ticks_diff +from kmk.modules import Module + + +class DSMeta: + def __init__(self, sequence_select=None): + self.sequence_select = sequence_select + + +class SequenceStatus: + STOPPED = const(0) + RECORDING = const(1) + PLAYING = const(2) + SET_REPEPITIONS = const(3) + SET_INTERVAL = const(4) + + +# Keycodes for number keys +_numbers = range(KC.N1.code, KC.N0.code + 1) + +SequenceFrame = namedtuple('SequenceFrame', ['keys_pressed', 'timestamp']) + + +class Sequence: + def __init__(self): + self.repetitions = 1 + self.interval = 0 + self.sequence_data = [SequenceFrame(set(), 0) for i in range(3)] + + +class DynamicSequences(Module): + def __init__( + self, slots=1, timeout=60000, key_interval=0, use_recorded_speed=False + ): + self.sequences = [Sequence() for i in range(slots)] + self.current_slot = self.sequences[0] + self.status = SequenceStatus.STOPPED + + self.index = 0 + self.start_time = 0 + self.current_repetition = 0 + self.last_config_frame = set() + + self.timeout = timeout + self.key_interval = key_interval + self.use_recorded_speed = use_recorded_speed + + # Create keycodes + make_argumented_key( + validator=DSMeta, names=('RECORD_SEQUENCE',), on_press=self._record_sequence + ) + + make_argumented_key( + validator=DSMeta, names=('PLAY_SEQUENCE',), on_press=self._play_sequence + ) + + make_argumented_key( + validator=DSMeta, + names=( + 'SET_SEQUENCE', + 'STOP_SEQUENCE', + ), + on_press=self._stop_sequence, + ) + + make_argumented_key( + validator=DSMeta, + names=('SET_SEQUENCE_REPETITIONS',), + on_press=self._set_sequence_repetitions, + ) + + make_argumented_key( + validator=DSMeta, + names=('SET_SEQUENCE_INTERVAL',), + on_press=self._set_sequence_interval, + ) + + def _record_sequence(self, key, keyboard, *args, **kwargs): + self._stop_sequence(key, keyboard) + self.status = SequenceStatus.RECORDING + self.start_time = ticks_ms() + self.current_slot.sequence_data = [SequenceFrame(set(), 0)] + self.index = 0 + + def _play_sequence(self, key, keyboard, *args, **kwargs): + self._stop_sequence(key, keyboard) + self.status = SequenceStatus.PLAYING + self.start_time = ticks_ms() + self.index = 0 + self.current_repetition = 0 + + def _stop_sequence(self, key, keyboard, *args, **kwargs): + if self.status == SequenceStatus.RECORDING: + self.stop_recording() + elif self.status == SequenceStatus.SET_INTERVAL: + self.stop_config() + self.status = SequenceStatus.STOPPED + + # Change sequences here because stop is always called + if key.meta.sequence_select is not None: + self.current_slot = self.sequences[key.meta.sequence_select] + + # Configure repeat settings + def _set_sequence_repetitions(self, key, keyboard, *args, **kwargs): + self._stop_sequence(key, keyboard) + self.status = SequenceStatus.SET_REPEPITIONS + self.last_config_frame = set() + self.current_slot.repetitions = 0 + self.start_time = ticks_ms() + + def _set_sequence_interval(self, key, keyboard, *args, **kwargs): + self._stop_sequence(key, keyboard) + self.status = SequenceStatus.SET_INTERVAL + self.last_config_frame = set() + self.current_slot.interval = 0 + self.start_time = ticks_ms() + + # Add the current keypress state to the sequence + def record_frame(self, keys_pressed): + if self.current_slot.sequence_data[self.index].keys_pressed != keys_pressed: + self.index += 1 + + # Recorded speed + if self.use_recorded_speed: + self.current_slot.sequence_data.append( + SequenceFrame( + keys_pressed.copy(), ticks_diff(ticks_ms(), self.start_time) + ) + ) + + # Constant speed + else: + self.current_slot.sequence_data.append( + SequenceFrame(keys_pressed.copy(), self.index * self.key_interval) + ) + + if not check_deadline(ticks_ms(), self.start_time, self.timeout): + self.stop_recording() + + # Add the ending frames to the sequence + def stop_recording(self): + # Clear the remaining keys + self.current_slot.sequence_data.append( + SequenceFrame(set(), self.current_slot.sequence_data[-1].timestamp + 20) + ) + + # Wait for the specified interval + prev_timestamp = self.current_slot.sequence_data[-1].timestamp + self.current_slot.sequence_data.append( + SequenceFrame( + set(), + prev_timestamp + self.current_slot.interval * 1000, + ) + ) + + self.status = SequenceStatus.STOPPED + + def play_frame(self, keyboard): + # Send the keypresses at this point in the sequence + if not check_deadline( + ticks_ms(), + self.start_time, + self.current_slot.sequence_data[self.index].timestamp, + ): + if self.index: + prev = self.current_slot.sequence_data[self.index - 1].keys_pressed + cur = self.current_slot.sequence_data[self.index].keys_pressed + + for key in prev.difference(cur): + keyboard.remove_key(key) + for key in cur.difference(prev): + keyboard.add_key(key) + + self.index += 1 + if self.index >= len(self.current_slot.sequence_data): # Reached the end + self.current_repetition += 1 + if self.current_repetition == self.current_slot.repetitions: + self.status = SequenceStatus.STOPPED + else: + self.index = 0 + self.start_time = ticks_ms() + + # Configuration for repeating sequences + def config_mode(self, keyboard): + for key in keyboard.keys_pressed.difference(self.last_config_frame): + if key.code in _numbers: + digit = (key.code - KC.N1.code + 1) % 10 + if self.status == SequenceStatus.SET_REPEPITIONS: + self.current_slot.repetitions = ( + self.current_slot.repetitions * 10 + digit + ) + elif self.status == SequenceStatus.SET_INTERVAL: + self.current_slot.interval = self.current_slot.interval * 10 + digit + + elif key.code == KC.ENTER.code: + self.stop_config() + + self.last_config_frame = keyboard.keys_pressed.copy() + keyboard.hid_pending = False # Disable typing + + if not check_deadline(ticks_ms(), self.start_time, self.timeout): + self.stop_config() + + # Finish configuring repetitions + def stop_config(self): + self.current_slot.sequence_data[-1] = SequenceFrame( + self.current_slot.sequence_data[-1].keys_pressed, + self.current_slot.sequence_data[-2].timestamp + + self.current_slot.interval * 1000, + ) + self.current_slot.repetitions = max(self.current_slot.repetitions, 1) + self.status = SequenceStatus.STOPPED + + def on_runtime_enable(self, keyboard): + return + + def on_runtime_disable(self, keyboard): + return + + 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): + + if not self.status: + return + + elif self.status == SequenceStatus.RECORDING: + self.record_frame(keyboard.keys_pressed) + + elif self.status == SequenceStatus.PLAYING: + self.play_frame(keyboard) + + elif ( + self.status == SequenceStatus.SET_REPEPITIONS + or self.status == SequenceStatus.SET_INTERVAL + ): + self.config_mode(keyboard) + + 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/easypoint.py b/kmk/modules/easypoint.py new file mode 100644 index 0000000..bf7023c --- /dev/null +++ b/kmk/modules/easypoint.py @@ -0,0 +1,130 @@ +''' +Extension handles usage of AS5013 by AMS +''' + +from supervisor import ticks_ms + +from kmk.keys import AX +from kmk.modules import Module + +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.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 + AX.X.move(keyboard, x) + AX.Y.move(keyboard, y) + + 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 _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..6f67ae8 --- /dev/null +++ b/kmk/modules/encoder.py @@ -0,0 +1,312 @@ +# 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, divisor=4): + + self.is_inverted = is_inverted + self.divisor = divisor + + self._state = None + self._start_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: + # encoder moved + 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[0] == new_state[1]: + # an encoder returned to the previous + # position halfway, cancel rotation + if ( + self._start_state[0] == new_state[0] + and self._start_state[1] == new_state[1] + and self._movement <= 2 + ): + self._movement = 0 + self._direction = 0 + + # when the encoder made a full loop according to its divisor + elif self._movement >= self.divisor - 1: + # 1 full step is 4 movements (2 for high-resolution encoder), + # however, when rotated quickly, some steps may be missed. + # This makes it behave more naturally + real_movement = self._movement // self.divisor + 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()) + + # Rotation finished, reset to identify new movement + self._movement = 0 + self._direction = 0 + self._start_state = new_state + + 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, divisor=None): + super().__init__(is_inverted) + + # Divisor can be 4 or 2 depending on whether the detent + # on the encoder is defined by 2 or 4 pulses + self.divisor = divisor + + 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()) + self._start_state = self._state + + 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 + self.divisor = 4 + + 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: + new_encoder = GPIOEncoder(*pins) + # Set default divisor if unset + if new_encoder.divisor is None: + new_encoder.divisor = self.divisor + + # 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..29a3e12 --- /dev/null +++ b/kmk/modules/holdtap.py @@ -0,0 +1,266 @@ +from micropython import const + +from kmk.keys import KC, make_argumented_key +from kmk.modules import Module +from kmk.utils import Debug + +debug = Debug(__name__) + + +class ActivationType: + PRESSED = const(0) + RELEASED = const(1) + HOLD_TIMEOUT = const(2) + INTERRUPTED = const(3) + REPEAT = const(4) + + +class HoldTapRepeat: + NONE = const(0) + TAP = const(1) + HOLD = const(2) + ALL = 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 HoldTapKeyMeta: + def __init__( + self, + tap, + hold, + prefer_hold=True, + tap_interrupted=False, + tap_time=None, + repeat=HoldTapRepeat.NONE, + ): + self.tap = tap + self.hold = hold + self.prefer_hold = prefer_hold + self.tap_interrupted = tap_interrupted + self.tap_time = tap_time + self.repeat = repeat + + +class HoldTap(Module): + tap_time = 300 + + def __init__(self): + self.key_buffer = [] + self.key_states = {} + if KC.get('HT') == KC.NO: + make_argumented_key( + validator=HoldTapKeyMeta, + names=('HT',), + on_press=self.ht_pressed, + on_release=self.ht_released, + ) + + 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 + send_buffer = False + append_buffer = False + + for key, state in self.key_states.items(): + if key == current_key: + continue + if state.activated != ActivationType.PRESSED: + continue + + # holdtap isn't interruptable, resolves on ht_release or timeout. + if not key.meta.tap_interrupted and not key.meta.prefer_hold: + append_buffer = True + 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 + ) + append_buffer = True + send_buffer = True + + # if interrupt on release: store interrupting keys until one of them + # is released. + if key.meta.tap_interrupted and is_pressed: + append_buffer = True + + # apply changes with 'side-effects' on key_states or the loop behaviour + # outside the loop. + if append_buffer: + self.key_buffer.append((int_coord, current_key, is_pressed)) + current_key = None + + if send_buffer: + self.send_key_buffer(keyboard) + + 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): + '''Unless in repeat mode, do nothing yet, action resolves when key is released, timer expires or other key is pressed.''' + if key in self.key_states: + state = self.key_states[key] + keyboard.cancel_timeout(self.key_states[key].timeout_key) + + if state.activated == ActivationType.RELEASED: + state.activated = ActivationType.REPEAT + self.ht_activate_tap(key, keyboard, *args, **kwargs) + elif state.activated == ActivationType.HOLD_TIMEOUT: + self.ht_activate_hold(key, keyboard, *args, **kwargs) + elif state.activated == ActivationType.INTERRUPTED: + self.ht_activate_on_interrupt(key, keyboard, *args, **kwargs) + return + + 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 not in self.key_states: + return keyboard + + state = self.key_states[key] + keyboard.cancel_timeout(state.timeout_key) + repeat = key.meta.repeat & HoldTapRepeat.TAP + + if state.activated == ActivationType.HOLD_TIMEOUT: + # release hold + self.ht_deactivate_hold(key, keyboard, *args, **kwargs) + repeat = key.meta.repeat & HoldTapRepeat.HOLD + elif state.activated == ActivationType.INTERRUPTED: + # release tap + self.ht_deactivate_on_interrupt(key, keyboard, *args, **kwargs) + if key.meta.prefer_hold: + repeat = key.meta.repeat & HoldTapRepeat.HOLD + elif state.activated == ActivationType.PRESSED: + # press and release tap because key released within tap time + self.ht_activate_tap(key, keyboard, *args, **kwargs) + self.send_key_buffer(keyboard) + self.ht_deactivate_tap(key, keyboard, *args, **kwargs) + state.activated = ActivationType.RELEASED + self.send_key_buffer(keyboard) + elif state.activated == ActivationType.REPEAT: + state.activated = ActivationType.RELEASED + self.ht_deactivate_tap(key, keyboard, *args, **kwargs) + + # don't delete the key state right now in this case + if repeat: + if key.meta.tap_time is None: + tap_time = self.tap_time + else: + tap_time = key.meta.tap_time + state.timeout_key = keyboard.set_timeout( + tap_time, lambda: self.key_states.pop(key) + ) + else: + 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 debug.enabled: + debug(f'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): + if not self.key_buffer: + return + + reprocess = False + for (int_coord, key, is_pressed) in self.key_buffer: + keyboard.resume_process_key(self, key, is_pressed, int_coord, reprocess) + if isinstance(key.meta, HoldTapKeyMeta): + reprocess = True + + self.key_buffer.clear() + + def ht_activate_hold(self, key, keyboard, *args, **kwargs): + if debug.enabled: + debug('ht_activate_hold') + keyboard.resume_process_key(self, key.meta.hold, True) + + def ht_deactivate_hold(self, key, keyboard, *args, **kwargs): + if debug.enabled: + debug('ht_deactivate_hold') + keyboard.resume_process_key(self, key.meta.hold, False) + + def ht_activate_tap(self, key, keyboard, *args, **kwargs): + if debug.enabled: + debug('ht_activate_tap') + keyboard.resume_process_key(self, key.meta.tap, True) + + def ht_deactivate_tap(self, key, keyboard, *args, **kwargs): + if debug.enabled: + debug('ht_deactivate_tap') + keyboard.resume_process_key(self, key.meta.tap, False) + + def ht_activate_on_interrupt(self, key, keyboard, *args, **kwargs): + if debug.enabled: + debug('ht_activate_on_interrupt') + 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 debug.enabled: + debug('ht_deactivate_on_interrupt') + 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..89a4cf2 --- /dev/null +++ b/kmk/modules/layers.py @@ -0,0 +1,184 @@ +'''One layer isn't enough. Adds keys to get to more of them''' +from kmk.keys import KC, make_argumented_key +from kmk.modules.holdtap import HoldTap, HoldTapKeyMeta +from kmk.utils import Debug + +debug = Debug(__name__) + + +def layer_key_validator(layer, kc=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, kc) + + +def layer_key_validator_lt(layer, kc, prefer_hold=False, **kwargs): + return HoldTapKeyMeta(tap=kc, hold=KC.MO(layer), prefer_hold=prefer_hold, **kwargs) + + +def layer_key_validator_tt(layer, prefer_hold=True, **kwargs): + return HoldTapKeyMeta( + tap=KC.TG(layer), hold=KC.MO(layer), prefer_hold=prefer_hold, **kwargs + ) + + +class LayerKeyMeta: + def __init__(self, layer, kc=None): + self.layer = layer + self.kc = kc + + +class Layers(HoldTap): + '''Gives access to the keys used to enable the layer system''' + + _active_combo = None + + def __init__( + self, + combo_layers=None, + ): + # Layers + super().__init__() + self.combo_layers = combo_layers + 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=('TG',), on_press=self._tg_pressed + ) + make_argumented_key( + validator=layer_key_validator, names=('TO',), on_press=self._to_pressed + ) + make_argumented_key( + validator=layer_key_validator_lt, + names=('LT',), + on_press=self.ht_pressed, + on_release=self.ht_released, + ) + make_argumented_key( + validator=layer_key_validator_tt, + names=('TT',), + on_press=self.ht_pressed, + on_release=self.ht_released, + ) + + def _df_pressed(self, key, keyboard, *args, **kwargs): + ''' + Switches the default layer + ''' + self.activate_layer(keyboard, key.meta.layer, as_default=True) + + def _mo_pressed(self, key, keyboard, *args, **kwargs): + ''' + Momentarily activates layer, switches off when you let go + ''' + self.activate_layer(keyboard, key.meta.layer) + + def _mo_released(self, key, keyboard, *args, **kwargs): + self.deactivate_layer(keyboard, key.meta.layer) + + def _lm_pressed(self, key, keyboard, *args, **kwargs): + ''' + As MO(layer) but with mod active + ''' + keyboard.hid_pending = True + keyboard.keys_pressed.add(key.meta.kc) + self.activate_layer(keyboard, key.meta.layer) + + 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.deactivate_layer(keyboard, key.meta.layer) + + 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 + if key.meta.layer in keyboard.active_layers: + self.deactivate_layer(keyboard, key.meta.layer) + else: + self.activate_layer(keyboard, key.meta.layer) + + def _to_pressed(self, key, keyboard, *args, **kwargs): + ''' + Activates layer and deactivates all other layers + ''' + self._active_combo = None + keyboard.active_layers.clear() + keyboard.active_layers.insert(0, key.meta.layer) + + def _print_debug(self, keyboard): + if debug.enabled: + debug(f'active_layers={keyboard.active_layers}') + + def activate_layer(self, keyboard, layer, as_default=False): + if as_default: + keyboard.active_layers[-1] = layer + else: + keyboard.active_layers.insert(0, layer) + + if self.combo_layers: + self._activate_combo_layer(keyboard) + + self._print_debug(keyboard) + + def deactivate_layer(self, keyboard, layer): + # 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(layer) + del keyboard.active_layers[del_idx] + except ValueError: + if debug.enabled: + debug(f'_mo_released: layer {layer} not active') + + if self.combo_layers: + self._deactivate_combo_layer(keyboard, layer) + + self._print_debug(keyboard) + + def _activate_combo_layer(self, keyboard): + if self._active_combo: + return + + for combo, result in self.combo_layers.items(): + matching = True + for layer in combo: + if layer not in keyboard.active_layers: + matching = False + break + + if matching: + self._active_combo = combo + keyboard.active_layers.insert(0, result) + break + + def _deactivate_combo_layer(self, keyboard, layer): + if self._active_combo and layer in self._active_combo: + keyboard.active_layers.remove(self.combo_layers[self._active_combo]) + self._active_combo = None 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..7c6d509 --- /dev/null +++ b/kmk/modules/modtap.py @@ -0,0 +1,14 @@ +from kmk.keys import make_argumented_key +from kmk.modules.holdtap import HoldTap, HoldTapKeyMeta + + +# Deprecation Notice: The `ModTap` class serves as an alias for `HoldTap` and will be removed in a future update. Please use `HoldTap` instead. +class ModTap(HoldTap): + def __init__(self): + super().__init__() + make_argumented_key( + validator=HoldTapKeyMeta, + names=('MT',), + on_press=self.ht_pressed, + on_release=self.ht_released, + ) diff --git a/kmk/modules/mouse_keys.py b/kmk/modules/mouse_keys.py new file mode 100644 index 0000000..55dacab --- /dev/null +++ b/kmk/modules/mouse_keys.py @@ -0,0 +1,180 @@ +from kmk.keys import AX, make_key, make_mouse_key +from kmk.kmktime import PeriodicTimer +from kmk.modules import Module + + +class MouseKeys(Module): + def __init__(self): + self._nav_key_activated = 0 + self._up_activated = False + self._down_activated = False + self._left_activated = False + self._right_activated = False + self._mw_up_activated = False + self._mw_down_activated = False + self.max_speed = 10 + self.acc_interval = 10 # Delta ms to apply acceleration + self.move_step = 1 + + make_mouse_key( + names=('MB_LMB',), + code=1, + ) + make_mouse_key( + names=('MB_MMB',), + code=4, + ) + make_mouse_key( + names=('MB_RMB',), + code=2, + ) + make_mouse_key( + names=('MB_BTN4',), + code=8, + ) + make_mouse_key( + names=('MB_BTN5',), + code=16, + ) + 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): + self._timer = PeriodicTimer(self.acc_interval) + + def before_matrix_scan(self, keyboard): + return + + def after_matrix_scan(self, keyboard): + if not self._timer.tick(): + return + + if self._nav_key_activated: + if self.move_step < self.max_speed: + self.move_step = self.move_step + 1 + if self._right_activated: + AX.X.move(keyboard, self.move_step) + if self._left_activated: + AX.X.move(keyboard, -self.move_step) + if self._up_activated: + AX.Y.move(keyboard, -self.move_step) + if self._down_activated: + AX.Y.move(keyboard, self.move_step) + + if self._mw_up_activated: + AX.W.move(keyboard, 1) + if self._mw_down_activated: + AX.W.move(keyboard, -1) + + 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 _mw_up_press(self, key, keyboard, *args, **kwargs): + self._mw_up_activated = True + + def _mw_up_release(self, key, keyboard, *args, **kwargs): + self._mw_up_activated = False + + def _mw_down_press(self, key, keyboard, *args, **kwargs): + self._mw_down_activated = True + + def _mw_down_release(self, key, keyboard, *args, **kwargs): + self._mw_down_activated = False + + # Mouse movement + def _reset_next_interval(self): + if self._nav_key_activated == 1: + 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 + + def _ms_up_release(self, key, keyboard, *args, **kwargs): + self._up_activated = False + self._nav_key_activated -= 1 + self._check_last() + + def _ms_down_press(self, key, keyboard, *args, **kwargs): + self._nav_key_activated += 1 + self._reset_next_interval() + self._down_activated = True + + def _ms_down_release(self, key, keyboard, *args, **kwargs): + self._down_activated = False + self._nav_key_activated -= 1 + self._check_last() + + def _ms_left_press(self, key, keyboard, *args, **kwargs): + self._nav_key_activated += 1 + self._reset_next_interval() + self._left_activated = True + + def _ms_left_release(self, key, keyboard, *args, **kwargs): + self._nav_key_activated -= 1 + self._left_activated = False + self._check_last() + + def _ms_right_press(self, key, keyboard, *args, **kwargs): + self._nav_key_activated += 1 + self._reset_next_interval() + self._right_activated = True + + def _ms_right_release(self, key, keyboard, *args, **kwargs): + self._nav_key_activated -= 1 + self._right_activated = False + self._check_last() diff --git a/kmk/modules/oneshot.py b/kmk/modules/oneshot.py new file mode 100644 index 0000000..d7dcf31 --- /dev/null +++ b/kmk/modules/oneshot.py @@ -0,0 +1,87 @@ +from kmk.keys import make_argumented_key +from kmk.modules.holdtap import ActivationType, HoldTap, HoldTapKeyMeta +from kmk.modules.layers import LayerKeyMeta +from kmk.utils import Debug + +debug = Debug(__name__) + + +class OneShotKeyMeta(HoldTapKeyMeta): + def __init__(self, kc, tap_time=None): + super().__init__(tap=kc, hold=kc, prefer_hold=False, tap_time=tap_time) + + +class OneShot(HoldTap): + tap_time = 1000 + + def __init__(self): + super().__init__() + make_argumented_key( + validator=OneShotKeyMeta, + 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 non-os keyup, or reset timeout and + stack multiple os keys.''' + send_buffer = False + + for key, state in self.key_states.items(): + if key == current_key: + continue + + if (isinstance(current_key.meta, OneShotKeyMeta)) or ( + isinstance(current_key.meta, LayerKeyMeta) + ): + keyboard.cancel_timeout(state.timeout_key) + if key.meta.tap_time is None: + tap_time = self.tap_time + else: + tap_time = key.meta.tap_time + state.timeout_key = keyboard.set_timeout( + tap_time, + lambda k=key: self.on_tap_time_expired(k, keyboard), + ) + 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: + if is_pressed: + send_buffer = True + self.key_buffer.insert(0, (None, key, False)) + + if send_buffer: + self.key_buffer.append((int_coord, current_key, is_pressed)) + current_key = None + + self.send_key_buffer(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) + self.send_key_buffer(keyboard) + 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 debug.enabled: + debug(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 diff --git a/kmk/modules/pimoroni_trackball.py b/kmk/modules/pimoroni_trackball.py new file mode 100644 index 0000000..4c5fd43 --- /dev/null +++ b/kmk/modules/pimoroni_trackball.py @@ -0,0 +1,314 @@ +''' +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 AX, KC, make_argumented_key, make_key +from kmk.kmktime import PeriodicTimer +from kmk.modules import Module + +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 TrackballHandlerKeyMeta: + def __init__(self, handler=0): + self.handler = handler + + +def layer_key_validator(handler): + return TrackballHandlerKeyMeta(handler=handler) + + +class TrackballMode: + '''Behaviour mode of trackball: mouse movement or vertical scroll''' + + MOUSE_MODE = const(0) + SCROLL_MODE = const(1) + + +class ScrollDirection: + '''Behaviour mode of scrolling: natural or reverse scrolling''' + + NATURAL = const(0) + REVERSE = const(1) + + +class TrackballHandler: + def handle(self, keyboard, trackball, x, y, switch, state): + raise NotImplementedError + + +class PointingHandler(TrackballHandler): + def handle(self, keyboard, trackball, x, y, switch, state): + if x: + AX.X.move(keyboard, x) + if y: + AX.Y.move(keyboard, y) + + if switch == 1: # Button pressed + keyboard.pre_process_key(KC.MB_LMB, is_pressed=True) + + if not state and trackball.previous_state is True: # Button released + keyboard.pre_process_key(KC.MB_LMB, is_pressed=False) + + trackball.previous_state = state + + +class ScrollHandler(TrackballHandler): + def __init__(self, scroll_direction=ScrollDirection.NATURAL): + self.scroll_direction = scroll_direction + + def handle(self, keyboard, trackball, x, y, switch, state): + if self.scroll_direction == ScrollDirection.REVERSE: + y = -y + + if y != 0: + AX.W.move(keyboard, y) + + if switch == 1: # Button pressed + keyboard.pre_process_key(KC.MB_LMB, is_pressed=True) + + if not state and trackball.previous_state is True: # Button released + keyboard.pre_process_key(KC.MB_LMB, is_pressed=False) + + trackball.previous_state = state + + +class KeyHandler(TrackballHandler): + x = 0 + y = 0 + + def __init__(self, up, right, down, left, press, axis_snap=0.25, steps=8): + self.up = up + self.right = right + self.down = down + self.left = left + self.press = press + self.axis_snap = axis_snap + self.steps = steps + + def handle(self, keyboard, trackball, x, y, switch, state): + if y and abs(x / y) < self.axis_snap: + x = 0 + if x and abs(y / x) < self.axis_snap: + y = 0 + + self.x += x + self.y += y + x_taps = self.x // self.steps + y_taps = self.y // self.steps + self.x %= self.steps + self.y %= self.steps + for i in range(x_taps, 0, 1): + keyboard.tap_key(self.left) + for i in range(x_taps, 0, -1): + keyboard.tap_key(self.right) + for i in range(y_taps, 0, 1): + keyboard.tap_key(self.up) + for i in range(y_taps, 0, -1): + keyboard.tap_key(self.down) + if switch and state: + keyboard.tap_key(self.press) + + +class Trackball(Module): + '''Module handles usage of Trackball Breakout by Pimoroni''' + + def __init__( + self, + i2c, + mode=TrackballMode.MOUSE_MODE, + address=I2C_ADDRESS, + angle_offset=ANGLE_OFFSET, + handlers=None, + ): + self.angle_offset = angle_offset + if not handlers: + handlers = [PointingHandler(), ScrollHandler()] + if mode == TrackballMode.SCROLL_MODE: + handlers.reverse() + self._i2c_address = address + self._i2c_bus = i2c + + self.mode = mode + self.previous_state = False # click state + self.handlers = handlers + self.current_handler = self.handlers[0] + self.polling_interval = 20 + + chip_id = struct.unpack('= len(self.handlers): + next_index = 0 + self.activate_handler(next_index) + + 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 _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_handler_press(self, key, keyboard, *args, **kwargs): + self.activate_handler(key.meta.handler) + + def _tb_handler_next_press(self, key, keyboard, *args, **kwargs): + self.next_handler() + + 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) + self.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 diff --git a/kmk/modules/potentiometer.py b/kmk/modules/potentiometer.py new file mode 100644 index 0000000..03a3dc7 --- /dev/null +++ b/kmk/modules/potentiometer.py @@ -0,0 +1,94 @@ +from analogio import AnalogIn +from supervisor import ticks_ms + +from kmk.modules import Module + + +class PotentiometerState: + def __init__(self, direction: int, position: int): + self.direction = direction + self.position = position + + +class Potentiometer: + def __init__(self, pin, move_callback, is_inverted=False): + self.is_inverted = is_inverted + self.read_pin = AnalogIn(pin) + self._direction = None + self._pos = self.get_pos() + self._timestamp = ticks_ms() + self.cb = move_callback + + # callback function on events. + self.on_move_do = lambda state: self.cb(state) + + def get_state(self) -> PotentiometerState: + return PotentiometerState( + direction=(self.is_inverted and -self._direction or self._direction), + position=(self.is_inverted and -self._pos or self._pos), + ) + + def get_pos(self): + ''' + Read from the analog pin assingned, truncate to 7 bits, + average over 10 readings, and return a value 0-127 + ''' + return int(sum([(self.read_pin.value >> 9) for i in range(10)]) / 10) + + def update_state(self): + self._direction = 0 + new_pos = self.get_pos() + if abs(new_pos - self._pos) > 2: + # movement detected! + if new_pos > self._pos: + self._direction = 1 + else: + self._direction = -1 + self._pos = new_pos + if self.on_move_do is not None: + self.on_move_do(self.get_state()) + + +class PotentiometerHandler(Module): + def __init__(self): + self.potentiometers = [] + self.pins = None + + def on_runtime_enable(self, keyboard): + return + + def on_runtime_disable(self, keyboard): + return + + def during_bootup(self, keyboard): + if self.pins: + for args in self.pins: + self.potentiometers.append(Potentiometer(*args)) + return + + def before_matrix_scan(self, keyboard): + ''' + Return value will be injected as an extra matrix update + ''' + for potentiometer in self.potentiometers: + potentiometer.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/power.py b/kmk/modules/power.py new file mode 100644 index 0000000..5dec7e8 --- /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) is False: + 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) is False) + + 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/rapidfire.py b/kmk/modules/rapidfire.py new file mode 100644 index 0000000..50720ea --- /dev/null +++ b/kmk/modules/rapidfire.py @@ -0,0 +1,103 @@ +from random import randint + +from kmk.keys import make_argumented_key +from kmk.modules import Module + + +class RapidFireMeta: + def __init__( + self, + kc, + interval=100, + timeout=200, + enable_interval_randomization=False, + randomization_magnitude=15, + toggle=False, + ): + self.kc = kc + self.interval = interval + self.timeout = timeout + self.enable_interval_randomization = enable_interval_randomization + self.randomization_magnitude = randomization_magnitude + self.toggle = toggle + + +class RapidFire(Module): + _active_keys = {} + _toggled_keys = [] + _waiting_keys = [] + + def __init__(self): + make_argumented_key( + validator=RapidFireMeta, + names=('RF',), + on_press=self._rf_pressed, + on_release=self._rf_released, + ) + + def _get_repeat(self, key): + if key.meta.enable_interval_randomization: + return key.meta.interval + randint( + -key.meta.randomization_magnitude, key.meta.randomization_magnitude + ) + return key.meta.interval + + def _on_timer_timeout(self, key, keyboard): + keyboard.tap_key(key.meta.kc) + if key in self._waiting_keys: + self._waiting_keys.remove(key) + if key.meta.toggle and key not in self._toggled_keys: + self._toggled_keys.append(key) + self._active_keys[key] = keyboard.set_timeout( + self._get_repeat(key), lambda: self._on_timer_timeout(key, keyboard) + ) + + def _rf_pressed(self, key, keyboard, *args, **kwargs): + if key in self._toggled_keys: + self._toggled_keys.remove(key) + self._deactivate_key(key, keyboard) + return + if key.meta.timeout > 0: + keyboard.tap_key(key.meta.kc) + self._waiting_keys.append(key) + self._active_keys[key] = keyboard.set_timeout( + key.meta.timeout, lambda: self._on_timer_timeout(key, keyboard) + ) + else: + self._on_timer_timeout(key, keyboard) + + def _rf_released(self, key, keyboard, *args, **kwargs): + if key not in self._active_keys: + return + if key in self._toggled_keys: + if key not in self._waiting_keys: + return + self._toggled_keys.remove(key) + if key in self._waiting_keys: + self._waiting_keys.remove(key) + self._deactivate_key(key, keyboard) + + def _deactivate_key(self, key, keyboard): + keyboard.cancel_timeout(self._active_keys[key]) + self._active_keys.pop(key) + + def during_bootup(self, keyboard): + 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): + return diff --git a/kmk/modules/serialace.py b/kmk/modules/serialace.py new file mode 100644 index 0000000..2cbdcc2 --- /dev/null +++ b/kmk/modules/serialace.py @@ -0,0 +1,63 @@ +from usb_cdc import data + +from kmk.modules import Module +from kmk.utils import Debug + +debug = Debug(__name__) + + +class SerialACE(Module): + buffer = bytearray() + + def during_bootup(self, keyboard): + try: + data.timeout = 0 + except AttributeError: + pass + + def before_matrix_scan(self, keyboard): + pass + + def after_matrix_scan(self, keyboard): + pass + + def process_key(self, keyboard, key, is_pressed, int_coord): + return key + + def before_hid_send(self, keyboard): + # Serial.data isn't initialized. + if not data: + return + + # Nothing to parse. + if data.in_waiting == 0: + return + + self.buffer.extend(data.read()) + idx = self.buffer.find(b'\n') + + # No full command yet. + if idx == -1: + return + + # Split off command and evaluate. + line = self.buffer[:idx] + self.buffer = self.buffer[idx + 1 :] + + try: + if debug.enabled: + debug(f'eval({line})') + ret = eval(line, {'keyboard': keyboard}) + data.write(bytearray(str(ret) + '\n')) + except Exception as err: + if debug.enabled: + debug(f'error: {err}') + + def after_hid_send(self, keyboard): + pass + + def on_powersave_enable(self, keyboard): + pass + + def on_powersave_disable(self, keyboard): + pass diff --git a/kmk/modules/split.py b/kmk/modules/split.py new file mode 100644 index 0000000..2d5b4a0 --- /dev/null +++ b/kmk/modules/split.py @@ -0,0 +1,384 @@ +'''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, + 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.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/sticky_mod.py b/kmk/modules/sticky_mod.py new file mode 100644 index 0000000..73ad569 --- /dev/null +++ b/kmk/modules/sticky_mod.py @@ -0,0 +1,63 @@ +from kmk.keys import make_argumented_key +from kmk.modules import Module + + +class StickyModMeta: + def __init__(self, kc, mod): + self.kc = kc + self.mod = mod + + +class StickyMod(Module): + def __init__(self): + self._active = False + self._active_key = None + make_argumented_key( + names=('SM',), + validator=StickyModMeta, + on_press=self.sm_pressed, + on_release=self.sm_released, + ) + + def during_bootup(self, keyboard): + return + + def before_matrix_scan(self, keyboard): + return + + def process_key(self, keyboard, key, is_pressed, int_coord): + # release previous key if any other key is pressed + if self._active and self._active_key is not None: + self.release_key(keyboard, self._active_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 + + def release_key(self, keyboard, key): + keyboard.process_key(key.meta.mod, False) + self._active = False + self._active_key = None + + def sm_pressed(self, key, keyboard, *args, **kwargs): + keyboard.process_key(key.meta.mod, True) + keyboard.process_key(key.meta.kc, True) + self._active_key = key + + def sm_released(self, key, keyboard, *args, **kwargs): + keyboard.process_key(key.meta.kc, False) + self._active_key = key + self._active = True diff --git a/kmk/modules/string_substitution.py b/kmk/modules/string_substitution.py new file mode 100644 index 0000000..cd72f55 --- /dev/null +++ b/kmk/modules/string_substitution.py @@ -0,0 +1,222 @@ +try: + from typing import Optional +except ImportError: + # we're not in a dev environment, so we don't need to worry about typing + pass +from micropython import const + +from kmk.keys import KC, Key, ModifierKey +from kmk.modules import Module + + +class State: + LISTENING = const(0) + DELETING = const(1) + SENDING = const(2) + IGNORING = const(3) + + +class Character: + '''Helper class for making a left-shifted key identical to a right-shifted key''' + + is_shifted: bool = False + + def __init__(self, key_code: Key, is_shifted: bool) -> None: + self.is_shifted = is_shifted + self.key_code = KC.LSHIFT(key_code) if is_shifted else key_code + + def __eq__(self, other: any) -> bool: # type: ignore + try: + return ( + self.key_code.code == other.key_code.code + and self.is_shifted == other.is_shifted + ) + except AttributeError: + return False + + +class Phrase: + '''Manages a collection of characters and keeps an index of them so that potential matches can be tracked''' + + def __init__(self, string: str) -> None: + self._characters: list[Character] = [] + self._index: int = 0 + for char in string: + key_code = KC[char] + if key_code == KC.NO: + raise ValueError(f'Invalid character in dictionary: {char}') + shifted = char.isupper() or key_code.has_modifiers == {2} + self._characters.append(Character(key_code, shifted)) + + def next_character(self) -> None: + '''Increment the current index for this phrase''' + if not self.index_at_end(): + self._index += 1 + + def get_character_at_index(self, index: int) -> Character: + '''Returns the character at the given index''' + return self._characters[index] + + def get_character_at_current_index(self) -> Character: + '''Returns the character at the current index for this phrase''' + return self._characters[self._index] + + def reset_index(self) -> None: + '''Reset the index to the start of the phrase''' + self._index = 0 + + def index_at_end(self) -> bool: + '''Returns True if the index is at the end of the phrase''' + return self._index == len(self._characters) + + def character_is_at_current_index(self, character) -> bool: + '''Returns True if the given character is the next character in the phrase''' + return self.get_character_at_current_index() == character + + +class Rule: + '''Represents the relationship between a phrase to be substituted and its substitution''' + + def __init__(self, to_substitute: Phrase, substitution: Phrase) -> None: + self.to_substitute: Phrase = to_substitute + self.substitution: Phrase = substitution + + def restart(self) -> None: + '''Resets this rule's to_substitute and substitution phrases''' + self.to_substitute.reset_index() + self.substitution.reset_index() + + +class StringSubstitution(Module): + _shifted: bool = False + _rules: list = [] + _state: State = State.LISTENING + _matched_rule: Optional[Phrase] = None + _active_modifiers: list[ModifierKey] = [] + + def __init__( + self, + dictionary: dict, + ): + for key, value in dictionary.items(): + self._rules.append(Rule(Phrase(key), Phrase(value))) + + def process_key(self, keyboard, key, is_pressed, int_coord): + if key is KC.LSFT or key is KC.RSFT: + if is_pressed: + self._shifted = True + else: + self._shifted = False + + # control ignoring state if the key is a non-shift modifier + elif type(key) is ModifierKey: + if is_pressed and key not in self._active_modifiers: + self._active_modifiers.append(key) + self._state = State.IGNORING + elif key in self._active_modifiers: + self._active_modifiers.remove(key) + if not self._active_modifiers: + self._state = State.LISTENING + # reset rules because pressing a modifier combination + # should interrupt any current matches + for rule in self._rules: + rule.restart() + + if not self._state == State.LISTENING: + return key + + if is_pressed: + character = Character(key, self._shifted) + + # run through the dictionary to check for a possible match on each new keypress + for rule in self._rules: + if rule.to_substitute.character_is_at_current_index(character): + rule.to_substitute.next_character() + else: + rule.restart() + # if character is not a match at the current index, + # it could still be a match at the start of the sequence + # so redo the check after resetting the sequence + if rule.to_substitute.character_is_at_current_index(character): + rule.to_substitute.next_character() + # we've matched all of the characters in a phrase to be substituted + if rule.to_substitute.index_at_end(): + rule.restart() + # set the phrase indexes to where they differ + # so that only the characters that differ are replaced + for character in rule.to_substitute._characters: + if ( + character + == rule.substitution.get_character_at_current_index() + ): + rule.to_substitute.next_character() + rule.substitution.next_character() + else: + break + if rule.to_substitute.index_at_end(): + break + self._matched_rule = rule + self._state = State.DELETING + # if we have a match there's no reason to continue the full key processing, so return out + return + return key + + def during_bootup(self, keyboard): + return + + def before_matrix_scan(self, keyboard): + return + + def before_hid_send(self, keyboard): + + if self._state == State.LISTENING: + return + + if self._state == State.DELETING: + # force-release modifiers so sending the replacement text doesn't interact with them + # it should not be possible for any modifiers other than shift to be held upon rule activation + # as a modified key won't send a keycode that is matched against the user's dictionary, + # but, just in case, we'll release those too + modifiers_to_release = [ + KC.LSFT, + KC.RSFT, + KC.LCTL, + KC.LGUI, + KC.LALT, + KC.RCTL, + KC.RGUI, + KC.RALT, + ] + for modifier in modifiers_to_release: + keyboard.remove_key(modifier) + + # send backspace taps equivalent to the length of the phrase to be substituted + to_substitute: Phrase = self._matched_rule.to_substitute # type: ignore + to_substitute.next_character() + if not to_substitute.index_at_end(): + keyboard.tap_key(KC.BSPC) + else: + self._state = State.SENDING + + if self._state == State.SENDING: + substitution = self._matched_rule.substitution # type: ignore + if not substitution.index_at_end(): + keyboard.tap_key(substitution.get_character_at_current_index().key_code) + substitution.next_character() + else: + self._state = State.LISTENING + self._matched_rule = None + for rule in self._rules: + rule.restart() + + 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/tapdance.py b/kmk/modules/tapdance.py new file mode 100644 index 0000000..b78d35d --- /dev/null +++ b/kmk/modules/tapdance.py @@ -0,0 +1,122 @@ +from kmk.keys import KC, make_argumented_key +from kmk.modules.holdtap import ActivationType, HoldTap, HoldTapKeyMeta + + +class TapDanceKeyMeta: + def __init__(self, *keys, tap_time=None): + ''' + Any key in the tapdance sequence that is not already a holdtap + key gets converted to a holdtap key with identical tap and hold + meta attributes. + ''' + self.tap_time = tap_time + self.keys = [] + + for key in keys: + if not isinstance(key.meta, HoldTapKeyMeta): + ht_key = KC.HT( + tap=key, + hold=key, + prefer_hold=True, + tap_interrupted=False, + tap_time=self.tap_time, + ) + self.keys.append(ht_key) + else: + self.keys.append(key) + self.keys = tuple(self.keys) + + +class TapDance(HoldTap): + def __init__(self): + super().__init__() + make_argumented_key( + validator=TapDanceKeyMeta, + names=('TD',), + on_press=self.td_pressed, + on_release=self.td_released, + ) + + self.td_counts = {} + + def process_key(self, keyboard, key, is_pressed, int_coord): + if isinstance(key.meta, TapDanceKeyMeta): + if key in self.td_counts: + return key + + for _key, state in self.key_states.copy().items(): + if state.activated == ActivationType.RELEASED: + keyboard.cancel_timeout(state.timeout_key) + self.ht_activate_tap(_key, keyboard) + self.send_key_buffer(keyboard) + self.ht_deactivate_tap(_key, keyboard) + keyboard.resume_process_key(self, key, is_pressed, int_coord) + key = None + + del self.key_states[_key] + del self.td_counts[state.tap_dance] + + key = super().process_key(keyboard, key, is_pressed, int_coord) + + return key + + def td_pressed(self, key, keyboard, *args, **kwargs): + # active tap dance + if key in self.td_counts: + count = self.td_counts[key] + kc = key.meta.keys[count] + keyboard.cancel_timeout(self.key_states[kc].timeout_key) + + count += 1 + + # Tap dance reached the end of the list: send last tap in sequence + # and start from the beginning. + if count >= len(key.meta.keys): + self.key_states[kc].activated = ActivationType.RELEASED + self.on_tap_time_expired(kc, keyboard) + count = 0 + else: + del self.key_states[kc] + + # new tap dance + else: + count = 0 + + current_key = key.meta.keys[count] + + self.ht_pressed(current_key, keyboard, *args, **kwargs) + self.td_counts[key] = count + + # Add the active tap dance to key_states; `on_tap_time_expired` needs + # the back-reference. + self.key_states[current_key].tap_dance = key + + def td_released(self, key, keyboard, *args, **kwargs): + try: + kc = key.meta.keys[self.td_counts[key]] + except KeyError: + return + state = self.key_states[kc] + if state.activated == ActivationType.HOLD_TIMEOUT: + # release hold + self.ht_deactivate_hold(kc, keyboard, *args, **kwargs) + del self.key_states[kc] + del self.td_counts[key] + elif state.activated == ActivationType.INTERRUPTED: + # release tap + self.ht_deactivate_on_interrupt(kc, keyboard, *args, **kwargs) + del self.key_states[kc] + del self.td_counts[key] + else: + # keep counting + state.activated = ActivationType.RELEASED + + def on_tap_time_expired(self, key, keyboard, *args, **kwargs): + # Note: the `key` argument is the current holdtap key in the sequence, + # not the tapdance key. + state = self.key_states[key] + if state.activated == ActivationType.RELEASED: + self.ht_activate_tap(key, keyboard, *args, **kwargs) + self.send_key_buffer(keyboard) + del self.td_counts[state.tap_dance] + super().on_tap_time_expired(key, keyboard, *args, **kwargs) diff --git a/kmk/quickpin/pro_micro/avr_promicro.py b/kmk/quickpin/pro_micro/avr_promicro.py new file mode 100644 index 0000000..e8396b6 --- /dev/null +++ b/kmk/quickpin/pro_micro/avr_promicro.py @@ -0,0 +1,20 @@ +translate = { + 'D3': 0, + 'D2': 1, + 'D1': 4, + 'D0': 5, + 'D4': 6, + 'C6': 7, + 'D7': 8, + 'E6': 9, + 'B4': 10, + 'B5': 11, + 'B6': 12, + 'B2': 13, + 'B3': 14, + 'B1': 15, + 'F7': 16, + 'F6': 17, + 'F5': 18, + 'F4': 19, +} diff --git a/kmk/quickpin/pro_micro/boardsource_blok.py b/kmk/quickpin/pro_micro/boardsource_blok.py new file mode 100644 index 0000000..713cbe1 --- /dev/null +++ b/kmk/quickpin/pro_micro/boardsource_blok.py @@ -0,0 +1,28 @@ +import board + +pinout = [ + board.TX, + board.RX, + None, # GND + None, # GND + board.SDA, + board.SCL, + board.GP04, + board.GP05, + board.GP06, + board.GP07, + board.GP08, + board.GP09, + board.GP21, + board.GP23, + board.GP20, + board.GP22, + board.GP26, + board.GP27, + board.GP28, + board.GP29, + None, # 3.3v + None, # RST + None, # GND + None, # RAW +] diff --git a/kmk/quickpin/pro_micro/helios.py b/kmk/quickpin/pro_micro/helios.py new file mode 100644 index 0000000..6924254 --- /dev/null +++ b/kmk/quickpin/pro_micro/helios.py @@ -0,0 +1,28 @@ +import board + +pinout = [ + board.TX, + board.RX, + None, # GND + None, # GND + board.GP2, + board.GP3, + board.GP4, + board.GP5, + board.GP6, + board.GP7, + board.GP8, + board.GP9, + board.CS, + board.SDO, + board.SDI, + board.SCK, + board.GP26, + board.GP27, + board.GP28, + board.GP29, + None, # 3.3v + None, # RST + None, # GND + None, # RAW +] diff --git a/kmk/quickpin/pro_micro/kb2040.py b/kmk/quickpin/pro_micro/kb2040.py new file mode 100644 index 0000000..363de3d --- /dev/null +++ b/kmk/quickpin/pro_micro/kb2040.py @@ -0,0 +1,28 @@ +import board + +pinout = [ + board.D0, + board.D1, + None, # GND + None, # GND + board.D2, + board.D3, + board.D4, + board.D5, + board.D6, + board.D7, + board.D8, + board.D9, + board.D10, + board.MOSI, + board.MISO, + board.SCK, + board.A0, + board.A1, + board.A2, + board.A3, + None, # 3.3v + None, # RST + None, # GND + None, # RAW +] diff --git a/kmk/quickpin/pro_micro/nice_nano.py b/kmk/quickpin/pro_micro/nice_nano.py new file mode 100644 index 0000000..36d5f13 --- /dev/null +++ b/kmk/quickpin/pro_micro/nice_nano.py @@ -0,0 +1,28 @@ +import board + +pinout = [ + board.TX, + board.RX, + None, # GND + None, # GND + board.SDA, + board.SCL, + board.P0_22, + board.P0_24, + board.P1_00, + board.P0_11, + board.P1_04, + board.P1_06, + board.P0_09, + board.P0_10, + board.P1_11, + board.P1_13, + board.P1_15, + board.P0_02, + board.P0_29, + board.P0_31, + None, # 3.3v + None, # RST + None, # GND + None, # Battery+ +] diff --git a/kmk/quickpin/pro_micro/sparkfun_promicro_rp2040.py b/kmk/quickpin/pro_micro/sparkfun_promicro_rp2040.py new file mode 100644 index 0000000..13b1098 --- /dev/null +++ b/kmk/quickpin/pro_micro/sparkfun_promicro_rp2040.py @@ -0,0 +1,28 @@ +import board + +pinout = [ + board.TX, + board.RX, + None, # GND + None, # GND + board.D2, + board.D3, + board.D4, + board.D5, + board.D6, + board.D7, + board.D8, + board.D9, + board.D21, + board.MOSI, + board.MISO, + board.SCK, + board.D26, + board.D27, + board.D28, + board.D29, + None, # 3.3v + None, # RST + None, # GND + None, # RAW +] 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..39a88b6 --- /dev/null +++ b/kmk/scanners/digitalio.py @@ -0,0 +1,142 @@ +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(f'Invalid DiodeOrientation: {self.diode_orienttaion}') + + 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..c6b007a --- /dev/null +++ b/kmk/scanners/keypad.py @@ -0,0 +1,107 @@ +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. + ''' + + @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.keypad.events.get() + if ev and self.offset: + return keypad.Event(ev.key_number + self.offset, ev.pressed) + 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/scheduler.py b/kmk/scheduler.py new file mode 100644 index 0000000..1e14ebf --- /dev/null +++ b/kmk/scheduler.py @@ -0,0 +1,67 @@ +''' +Here we're abusing _asyncios TaskQueue to implement a very simple priority +queue task scheduler. +Despite documentation, Circuitpython doesn't usually ship with a min-heap +module; it does however implement a pairing-heap for `TaskQueue` in native code. +''' + +try: + from typing import Callable +except ImportError: + pass + +from supervisor import ticks_ms + +from _asyncio import Task, TaskQueue + +from kmk.kmktime import ticks_add, ticks_diff + +_task_queue = TaskQueue() + + +class PeriodicTaskMeta: + def __init__(self, func: Callable[[None], None], period: int) -> None: + self._task = Task(self.call) + self._coro = func + self.period = period + + def call(self) -> None: + self._coro() + after_ms = ticks_add(self._task.ph_key, self.period) + _task_queue.push_sorted(self._task, after_ms) + + +def create_task( + func: Callable[[None], None], + *, + after_ms: int = 0, + period_ms: int = 0, +) -> [Task, PeriodicTaskMeta]: + if period_ms: + r = PeriodicTaskMeta(func, period_ms) + t = r._task + else: + t = r = Task(func) + + if after_ms: + after_ms = ticks_add(ticks_ms(), after_ms) + _task_queue.push_sorted(t, after_ms) + else: + _task_queue.push_head(t) + + return r + + +def get_due_task() -> [Callable, None]: + while True: + t = _task_queue.peek() + if not t or ticks_diff(t.ph_key, ticks_ms()) > 0: + break + _task_queue.pop_head() + yield t.coro + + +def cancel_task(t: [Task, PeriodicTaskMeta]) -> None: + if isinstance(t, PeriodicTaskMeta): + t = t._task + _task_queue.remove(t) 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..0b30df6 --- /dev/null +++ b/kmk/transports/pio_uart.py @@ -0,0 +1,92 @@ +''' +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 rp2pio +from array import array + +''' +.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. + +; compiles to: +''' +tx_code = array('H', [40864, 63271, 24577, 1602]) + + +''' +.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 + +; compiles to: +''' +rx_code = array('H', [8224, 59943, 16385, 1602]) + + +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..4d05158 --- /dev/null +++ b/kmk/types.py @@ -0,0 +1,26 @@ +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 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 diff --git a/kmk/utils.py b/kmk/utils.py new file mode 100644 index 0000000..b27a04b --- /dev/null +++ b/kmk/utils.py @@ -0,0 +1,39 @@ +try: + from typing import Optional +except ImportError: + pass + +from supervisor import ticks_ms + + +def clamp(x: int, bottom: int = 0, top: int = 100) -> int: + return min(max(bottom, x), top) + + +_debug_enabled = False + + +class Debug: + '''default usage: + debug = Debug(__name__) + ''' + + def __init__(self, name: str = __name__): + self.name = name + + def __call__(self, *message: str, name: Optional[str] = None) -> None: + if not name: + name = self.name + print(ticks_ms(), end=' ') + print(name, end=': ') + print(*message, sep='') + + @property + def enabled(self) -> bool: + global _debug_enabled + return _debug_enabled + + @enabled.setter + def enabled(self, enabled: bool): + global _debug_enabled + _debug_enabled = enabled