Skip to content
Snippets Groups Projects
Verified Commit cd2e834a authored by xiretza's avatar xiretza
Browse files

feat(pycardium): Add button_gestures module

parent f1818479
No related branches found
No related tags found
No related merge requests found
Pipeline #3889 passed
...@@ -97,6 +97,7 @@ autodoc_mock_imports = [ ...@@ -97,6 +97,7 @@ autodoc_mock_imports = [
"ucollections", "ucollections",
"urandom", "urandom",
"utime", "utime",
"vibra",
] ]
autodoc_member_order = "bysource" autodoc_member_order = "bysource"
......
...@@ -26,6 +26,7 @@ Last but not least, if you want to start hacking the lower-level firmware, the ...@@ -26,6 +26,7 @@ Last but not least, if you want to start hacking the lower-level firmware, the
pycardium/bme680 pycardium/bme680
pycardium/max30001 pycardium/max30001
pycardium/buttons pycardium/buttons
pycardium/button_gestures
pycardium/color pycardium/color
pycardium/display pycardium/display
pycardium/gpio pycardium/gpio
......
``button_gestures`` - Button Gesture Detection
==============================================
.. automodule:: button_gestures
:members:
"""
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
_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]
def button_name(idx):
names = {
buttons.TOP_LEFT: "TopLeft",
buttons.TOP_RIGHT: "TopRight",
buttons.BOTTOM_LEFT: "BottomLeft",
buttons.BOTTOM_RIGHT: "BottomRight",
}
return names[idx]
def event_name(idx):
names = {
BUTTON_PRESSED: "pressed",
BUTTON_RELEASED: "released",
SHORT_PRESS: "short press",
MEDIUM_PRESS: "medium press",
LONG_PRESS: "long press",
DOUBLE_PRESS: "double press",
}
return names[idx]
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 "{}: {}".format(button_name(self.button), event_name(self.event))
def __repr__(self):
return str(self)
class GestureFeed:
"""
Recognizes various button gestures, provides force feedback for press
durations and emits events accordingly.
: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 force 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.feed():
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
):
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 _force_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_force_feedback(self, events, callback=None):
"""
Passes through a raw event stream (i.e. from :py:meth:`_raw_events`)
unchanged, but provides force 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._force_feedback
for ev in events:
yield ev
btn = self._buttons[ev.button]
enabled = btn.enabled_events
if ev.event in enabled:
callback(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 btn.current_duration == LONG_PRESS and LONG_PRESS in enabled:
yield ButtonEvent(ev.button, LONG_PRESS, ev.time)
elif btn.current_duration == MEDIUM_PRESS and MEDIUM_PRESS in enabled:
yield ButtonEvent(ev.button, MEDIUM_PRESS, ev.time)
elif btn.current_duration == SHORT_PRESS:
if 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]:
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
if DOUBLE_PRESS in btn.enabled_events:
# synthesize a double press
yield ButtonEvent(
ev.button, DOUBLE_PRESS, btn.last_double_press
)
btn.last_double_press = None
else:
if ev.event in [MEDIUM_PRESS, LONG_PRESS]:
# invalidate any previous short presses when a longer press is detected
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_force_feedback(
self._raw_events(btn_values=btn_values, curr_time=curr_time),
callback=feedback,
)
)
)
)
def feed(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_name(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.
"""
action()
def run(self, sleep_time=100):
"""
Run the mapper. 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
"""
for ev in self._feed.feed(sleep_time=sleep_time):
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)
...@@ -9,6 +9,7 @@ python_modules = files( ...@@ -9,6 +9,7 @@ python_modules = files(
'pride.py', 'pride.py',
'ledfx.py', 'ledfx.py',
'simple_menu.py', 'simple_menu.py',
'button_gestures.py',
# MicroPython Standard-Library # MicroPython Standard-Library
'col_defaultdict.py', 'col_defaultdict.py',
......
0% Loading or .
You are about to add 0 people to the discussion. Proceed with caution.
Please register or to comment