diff --git a/Documentation/conf.py b/Documentation/conf.py index 7f8e4771830e44071fcd05aa864f4e7ad0bc78b4..b5e4ec4f4c4a8ec85c76a683d91f7e616b331529 100644 --- a/Documentation/conf.py +++ b/Documentation/conf.py @@ -97,6 +97,7 @@ autodoc_mock_imports = [ "ucollections", "urandom", "utime", + "vibra", ] autodoc_member_order = "bysource" diff --git a/Documentation/index.rst b/Documentation/index.rst index 56631e81dfff20e8416a71d752fa93128535c18f..f551fc98608ab5700723b2b32b09887944e361cd 100644 --- a/Documentation/index.rst +++ b/Documentation/index.rst @@ -26,6 +26,7 @@ Last but not least, if you want to start hacking the lower-level firmware, the pycardium/bme680 pycardium/max30001 pycardium/buttons + pycardium/button_gestures pycardium/color pycardium/display pycardium/gpio diff --git a/Documentation/pycardium/button_gestures.rst b/Documentation/pycardium/button_gestures.rst new file mode 100644 index 0000000000000000000000000000000000000000..685dda3c32deec9dd8be13ef93576e4f85bc5050 --- /dev/null +++ b/Documentation/pycardium/button_gestures.rst @@ -0,0 +1,5 @@ +``button_gestures`` - Button Gesture Detection +============================================== + +.. automodule:: button_gestures + :members: diff --git a/pycardium/modules/py/button_gestures.py b/pycardium/modules/py/button_gestures.py new file mode 100644 index 0000000000000000000000000000000000000000..9c3653c1e806c8bc919fe408f1ac034f20eb35d2 --- /dev/null +++ b/pycardium/modules/py/button_gestures.py @@ -0,0 +1,568 @@ +""" +This module provides button gesture detection (short/medium/long press, double +press, ...) using either an event-stream (:py:class:`GestureFeed`) or a +key-map/functional (:py:class:`ActionMapper`) interface. + +Recognized events: + +* :py:const:`BUTTON_PRESSED`: The button has just been pressed +* :py:const:`BUTTON_RELEASED`: The button has just been released +* :py:const:`SHORT_PRESS` +* :py:const:`MEDIUM_PRESS` +* :py:const:`LONG_PRESS` +* :py:const:`DOUBLE_PRESS` +""" + +import buttons +from functools import reduce +import utime +import vibra +import sys + +_BUTTONS = [ + buttons.TOP_LEFT, + buttons.TOP_RIGHT, + buttons.BOTTOM_LEFT, + buttons.BOTTOM_RIGHT, +] + +BUTTON_PRESSED = 1 +BUTTON_RELEASED = 2 + +SHORT_PRESS = 3 +MEDIUM_PRESS = 4 +LONG_PRESS = 5 +DOUBLE_PRESS = 6 + +# represents a short press for double press detection when short presses are disabled +# only used internally +HALF_DOUBLE_PRESS = 7 + +# dummy event, emitted once per button for each _raw_events() call +NO_EVENT = 100 + + +def _is_dummy_event(ev): + return ev.event in [NO_EVENT] + + +class Button: + def __init__(self): + # Button has been pressed since this timestamp + self.last_pressed = 0 + + # The latest duration timeout that was reached, or None if not pressed + self.current_duration = None + + # The events that should be returned for this button + self.enabled_events = set() + + # If the current press has been handled already + self.press_handled = True + + # Timestamp of the last press if it was short, otherwise None + # (for double press detection) + self.last_double_press = None + + +class ButtonEvent: + """ + A gesture event. + + .. py:attribute:: button + + The ID of the button this event was triggered on + (e.g. :py:data:`buttons.BOTTOM_LEFT`) + + .. py:attribute:: event + + The Event that was triggered (e.g. :py:const:`SHORT_PRESS`) + """ + + def __init__(self, button, event, time): + self.button = button + self.event = event + self.time = time + + def __str__(self): + return "btn={}, ev={}".format(self.button, self.event) + + +class GestureFeed: + """ + Recognizes various button gestures, emits events accordingly and provides + tactile feedback for durations. + + :param int time_medium: The time in ms a button needs to be pressed to count as a medium press + :param int time_long: The time in ms a button needs to be pressed to count as a long press + :param int double_press_delay: The maximum time in ms between short presses to still count as a double press + :param int feedback_time: The duration in ms the vibration motor should be running for tactile feedback + + **Example**: + + .. code-block:: python + + import buttons + import button_gestures as bg + + feed = bg.GestureFeed() + feed.set_enabled_events(buttons.BOTTOM_LEFT, [ + bg.SHORT_PRESS, bg.MEDIUM_PRESS, bg.DOUBLE_PRESS + ]) + + for ev in feed.run(): + if ev.button == buttons.BOTTOM_LEFT and ev.event == bg.SHORT_PRESS: + print("Normal press!") + elif ev.button == buttons.BOTTOM_LEFT and ev.event == bg.MEDIUM_PRESS: + print("Slightly longer press!") + elif ev.button == buttons.BOTTOM_LEFT and ev.event == bg.DOUBLE_PRESS: + print("Double press!") + """ + + def __init__( + self, time_medium=400, time_long=1000, double_press_delay=400, feedback_time=20 + ): + if time_medium > 10000 or time_long > 10000: + raise ValueError("Sanity check failed: press duration over 10s") + + self.time_medium = time_medium + self.time_long = time_long + + self.double_press_delay = double_press_delay + + self.feedback_time = feedback_time + self._buttons = {b: Button() for b in _BUTTONS} + + def _vibra_feedback(self, ev): + if ev.event in [MEDIUM_PRESS, LONG_PRESS]: + vibra.vibrate(self.feedback_time) + + def _raw_events(self, btn_values=None, curr_time=None): + """ + This method generates the raw event stream used by all other methods. + Every time it is called, it updates internal state and yields all raw + events that have occured since the last call. + + Pressing a button immediately triggers these events: + + * :py:data:`BUTTON_PRESSED` + * :py:data:`SHORT_PRESS` + + Keeping a button pressed triggers the following events after their + respective timeout: + + * :py:data:`MEDIUM_PRESS` + * :py:data:`LONG_PRESS` + + When the button is released, a :py:data:`BUTTON_RELEASED` event is triggered. + + Additionally, a :py:data:`NO_EVENT` pseudo-event is generated for every + button on every call, which can be used by other event stream functions + (like :py:meth:`_add_double_presses`) to perform actions periodically, + even if no actual button events occured. + + :param int btn_values: Mock button values, used for debugging. + :param int curr_time: Mock timestamp, used for debugging. + """ + + if btn_values is None: + btn_values = buttons.read(reduce(lambda x, y: x | y, self._buttons.keys())) + + if curr_time is None: + curr_time = utime.time_ms() + + for index, btn in self._buttons.items(): + # whether the button is pressed right now + pressed = (btn_values & index) != 0 + + def make_event(event_id): + return ButtonEvent(index, event_id, curr_time) + + # insert a dummy event for each button, used to refresh double-press logic + # even when no actual change occured + yield make_event(NO_EVENT) + + if pressed and btn.current_duration is None: + # just pressed + btn.last_pressed = curr_time + yield make_event(BUTTON_PRESSED) + + btn.current_duration = SHORT_PRESS + yield make_event(SHORT_PRESS) + elif pressed: + # still pressed + duration = curr_time - btn.last_pressed + if btn.current_duration == SHORT_PRESS and duration > self.time_medium: + btn.current_duration = MEDIUM_PRESS + yield make_event(MEDIUM_PRESS) + + if btn.current_duration == MEDIUM_PRESS and duration > self.time_long: + btn.current_duration = LONG_PRESS + yield make_event(LONG_PRESS) + elif not pressed and btn.current_duration is not None: + # just released + yield make_event(BUTTON_RELEASED) + btn.current_duration = None + + def _add_feedback(self, events, callback=None): + """ + Passes through a raw event stream (i.e. from :py:meth:`_raw_events`) + unchanged, but provides tactile feedback for enabled events using the + vibration motor (or calls any arbitrary feedback function). + + :param events: The unfiltered event stream + :param function callback: The function to be called when any enabled event is emitted (default: pulse the vibration motor) + :type events: Iterable of ButtonEvents + + :return: The unchanged event stream + """ + + if callback is None: + callback = self._vibra_feedback + + for ev in events: + btn = self._buttons[ev.button] + enabled = btn.enabled_events + + if ev.event in enabled: + callback(ev) + + yield ev + + def _clean_events(self, events): + """ + Filters raw events (i.e. from :py:meth:`_raw_events`) to those enabled + for the respective button, deduplicates duration events and synthesizes + them when a button is released without one having been sent before + (e.g. if the button was held for only a short duration, but a long + duration is also enabled). + + Additionally, a virtual :py:data:`HALF_DOUBLE_PRESS` event is inserted + on short presses if double presses are enabled, so they can later be + accumulated by :py:meth:`_add_double_presses`. + + :param events: The unfiltered event stream + :type events: Iterable of ButtonEvents + + :return: The cleaned event stream + """ + + for ev in events: + btn = self._buttons[ev.button] + enabled = btn.enabled_events + + # pass through dummy events + if _is_dummy_event(ev): + yield ev + continue + + # a fresh press, mark it as unhandled + if ev.event == BUTTON_PRESSED: + btn.press_handled = False + + # synthesize duration event when button was released without one having been passed through before + elif ev.event == BUTTON_RELEASED and not btn.press_handled: + btn.press_handled = True + + if LONG_PRESS in enabled and btn.current_duration == LONG_PRESS: + yield ButtonEvent(ev.button, LONG_PRESS, ev.time) + elif MEDIUM_PRESS in enabled and btn.current_duration in [ + MEDIUM_PRESS, + LONG_PRESS, + ]: + yield ButtonEvent(ev.button, MEDIUM_PRESS, ev.time) + elif SHORT_PRESS in enabled: + yield ButtonEvent(ev.button, SHORT_PRESS, ev.time) + elif DOUBLE_PRESS in enabled: + # we need to detect double presses, but can't send short presses - + # use a dummy event instead + yield ButtonEvent(ev.button, HALF_DOUBLE_PRESS, ev.time) + + # pass through raw events if enabled + if ev.event == BUTTON_PRESSED and BUTTON_PRESSED in enabled: + yield ev + elif ev.event == BUTTON_RELEASED and BUTTON_RELEASED in enabled: + yield ev + + # duration events should only be triggered once + if btn.press_handled: + continue + + enabled_masked_short = enabled & { + DOUBLE_PRESS, + SHORT_PRESS, + MEDIUM_PRESS, + LONG_PRESS, + } + + # if a duration event comes in and no longer duration event is enabled for this button, send it immediately + if ev.event == SHORT_PRESS and ( + enabled_masked_short & {DOUBLE_PRESS, SHORT_PRESS} + == enabled_masked_short + and enabled_masked_short != set() + ): + # either short or double press are enabled, and neither medium nor long are + btn.press_handled = True + + if SHORT_PRESS in enabled: + yield ev + elif DOUBLE_PRESS in enabled: + # we need to detect double presses, but can't send short presses - use a dummy event instead + yield ButtonEvent(ev.button, HALF_DOUBLE_PRESS, ev.time) + elif ev.event == MEDIUM_PRESS and ( + enabled & {MEDIUM_PRESS, LONG_PRESS} + ) == {MEDIUM_PRESS}: + # medium press is enabled, and long press isn't + btn.press_handled = True + yield ev + elif ev.event == LONG_PRESS and (enabled & {LONG_PRESS}) == {LONG_PRESS}: + btn.press_handled = True + yield ev + + def _add_double_presses(self, events): + """ + Combines :py:data:`SHORT_PRESS`/:py:data:`HALF_DOUBLE_PRESS` events from + a cleaned event stream (i.e. from :py:meth:`_clean_events`) into + :py:data:`DOUBLE_PRESS` events if they are within a timeout, and passes + everything else through as-is. + + :param events: The cleaned event stream + :type events: Iterable of ButtonEvents + + :return: The same event stream, with :py:data:`DOUBLE_PRESS` events added and :py:data:`SHORT_PRESS` events suppressed + """ + + for ev in events: + btn = self._buttons[ev.button] + + # check for expired double presses + if btn.last_double_press is not None: + duration = ev.time - btn.last_double_press + if duration >= self.double_press_delay: + if SHORT_PRESS in btn.enabled_events: + # replay the previously swallowed short press + yield ButtonEvent(ev.button, SHORT_PRESS, btn.last_double_press) + + # reset marker + btn.last_double_press = None + + if ( + ev.event in [SHORT_PRESS, HALF_DOUBLE_PRESS] + and DOUBLE_PRESS in btn.enabled_events + ): + if btn.last_double_press is None: + # mark double press as armed, swallow short press event + btn.last_double_press = ev.time + else: + # there has been a recent short press, and since any + # expired markers have been discarded already, it has to be + # a complete double press now + yield ButtonEvent(ev.button, DOUBLE_PRESS, btn.last_double_press) + + btn.last_double_press = None + else: + if ( + ev.event in [MEDIUM_PRESS, LONG_PRESS] + and btn.last_double_press is not None + ): + # invalidate any previous short presses when a longer press is detected + if SHORT_PRESS in btn.enabled_events: + yield ButtonEvent(ev.button, SHORT_PRESS, btn.last_double_press) + btn.last_double_press = None + + # always pass through normal press events + yield ev + + def _remove_dummy_events(self, events): + return (ev for ev in events if not _is_dummy_event(ev)) + + def new_events(self, btn_values=None, curr_time=None, feedback=None): + """ + The output of all event stream functions chained together. This method + should be called periodically, and returns all events that have occured + since the last time it was called. + + :return: All events that have occured since the last call + :rtype: Iterable of :py:class:`ButtonEvent` + """ + + # function chaining for dummies + return self._remove_dummy_events( + self._add_double_presses( + self._clean_events( + self._add_feedback( + self._raw_events(btn_values=btn_values, curr_time=curr_time), + callback=feedback, + ) + ) + ) + ) + + def run(self, sleep_time=100): + """ + A wrapper around :py:meth:`new_events` that yields a continuous feed of + gesture events. + + :param sleep_time: The time in ms to sleep between button polling (or None to disable sleeping) + :type sleep_time: int or None + :return: An event feed + :rtype: Iterable of :py:class:`ButtonEvent` + """ + + while True: + for ev in self.new_events(): + yield ev + + if sleep_time is not None: + utime.sleep_ms(sleep_time) + + def disable_all_events(self): + """ + Disable all events on all buttons. Useful for cleaning up before + changing to another button layout. + """ + + for btn in self._buttons.values(): + btn.enabled_events.clear() + + def set_enabled_events(self, button, events): + """ + Set the events to watch for on a specific button. + + It is important to enable only the exact events required, otherwise + wanted events may be swallowed in favor of unused ones (example: only + short presses are required, but long presses are also enabled - pressing + a button for a longer period of time will not trigger a short press + event anymore) + + **Example**: + + .. code-block:: python + + import button_gestures as bg + + feed = bg.GestureFeed() + feed.set_enabled_events(buttons.BOTTOM_LEFT, [bg.SHORT_PRESS, bg.LONG_PRESS]) + feed.set_enabled_events(buttons.BOTTOM_RIGHT, [bg.DOUBLE_PRESS]) + """ + + if button not in self._buttons: + raise ValueError("Bad button index: {}".format(button)) + + self._buttons[button].enabled_events = set(events) + + +class ActionMapper: + """ + A convenience class that maps events to actions and manages the underlying + :py:class:`GestureFeed`. + + :param feed: An optional feed instance to use internally + :type feed: :py:class:`GestureFeed` or None + + **Example**: + + .. code-block:: python + + import sys + import button_gestures as bg + + def hello(): + print("Hello world!") + + mapper = bg.ActionMapper() + + mapping = { + (buttons.BOTTOM_LEFT, bg.SHORT_PRESS): lambda: print("Short!"), + (buttons.BOTTOM_LEFT, bg.MEDIUM_PRESS): lambda: print("Medium!"), + (buttons.BOTTOM_LEFT, bg.LONG_PRESS): lambda: print("Long!"), + (buttons.BOTTOM_LEFT, bg.DOUBLE_PRESS): hello, + (buttons.BOTTOM_RIGHT, bg.DOUBLE_PRESS): sys.exit, + } + + mapper.set_mapping(mapping) + mapper.run() + """ + + def __init__(self, feed=None): + self.disabled_actions = set() + + # mapping from events to actions + self._bindings = {} + + if feed is None: + self._feed = GestureFeed() + else: + self._feed = feed + + def set_mapping(self, mapping): + """ + Apply a button mapping (dictionary of (button, event) -> action). + + :param mapping: The actions to be bound to their respecive events + """ + + self._bindings = mapping + + self._feed.disable_all_events() + + # this would be the place for a defaultdict! Unfortunately that's + # completely broken in micropython-lib#b89114c (no way to iterate + # over it) + required_events = {} + + for btn, event in mapping.keys(): + if btn in required_events: + required_events[btn].append(event) + else: + required_events[btn] = [event] + + for btn, events in required_events.items(): + self._feed.set_enabled_events(btn, events) + + def on_action(self, event, action): + """ + By default, call the action as a function. If this is not desired, this + method can be overridden to use the event and action arbitrarily. + """ + + try: + action() + except Exception as e: + print("Action for event ({}) failed to execute:".format(event)) + sys.print_exception(e) + + def update(self): + """ + Poll for new events from the feed and call :py:meth:`on_action` for + each. Needs to be run periodically. + """ + + for ev in self._feed.new_events(): + ident = (ev.button, ev.event) + if ident not in self._bindings: + raise RuntimeError( + "Received event that isn't bound to anything, please report a bug for button_gestures ({})".format( + ev + ) + ) + else: + action = self._bindings[ident] + if action not in self.disabled_actions: + self.on_action(ev, action) + + def run(self, sleep_time=100): + """ + Run the mapper continuously. The buttons will be polled with the + specified interval, and :py:meth:`on_action` will be called any time a + mapped event is recognized. + + :param sleep_time: The time in ms to sleep after every button poll (or None to disable waiting) + :type sleep_time: int or None + """ + + while True: + self.update() + + if sleep_time is not None: + utime.sleep_ms(sleep_time) diff --git a/pycardium/modules/py/meson.build b/pycardium/modules/py/meson.build index d2669a19213f7d0f19d66b47e1dc4d4ebeac24c5..9f41c4df49336eb220d56807b6e627858638e795 100644 --- a/pycardium/modules/py/meson.build +++ b/pycardium/modules/py/meson.build @@ -9,6 +9,7 @@ python_modules = files( 'pride.py', 'ledfx.py', 'simple_menu.py', + 'button_gestures.py', # MicroPython Standard-Library 'col_defaultdict.py',