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',