-
Notifications
You must be signed in to change notification settings - Fork 1
Expand file tree
/
Copy pathbutton_handler.py
More file actions
652 lines (519 loc) · 23.9 KB
/
button_handler.py
File metadata and controls
652 lines (519 loc) · 23.9 KB
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
466
467
468
469
470
471
472
473
474
475
476
477
478
479
480
481
482
483
484
485
486
487
488
489
490
491
492
493
494
495
496
497
498
499
500
501
502
503
504
505
506
507
508
509
510
511
512
513
514
515
516
517
518
519
520
521
522
523
524
525
526
527
528
529
530
531
532
533
534
535
536
537
538
539
540
541
542
543
544
545
546
547
548
549
550
551
552
553
554
555
556
557
558
559
560
561
562
563
564
565
566
567
568
569
570
571
572
573
574
575
576
577
578
579
580
581
582
583
584
585
586
587
588
589
590
591
592
593
594
595
596
597
598
599
600
601
602
603
604
605
606
607
608
609
610
611
612
613
614
615
616
617
618
619
620
621
622
623
624
625
626
627
628
629
630
631
632
633
634
635
636
637
638
639
640
641
642
643
644
645
646
647
648
649
650
651
652
# SPDX-FileCopyrightText: 2017 Scott Shawcroft, written for Adafruit Industries
# SPDX-FileCopyrightText: Copyright (c) 2024, 2025 EGJ Moorington
#
# SPDX-License-Identifier: MIT
"""
`button_handler`
================================================================================
This helper library simplifies the usage of buttons with CircuitPython,
by detecting and differentiating button inputs,
returning a set of the inputs and calling their corresponding functions.
* Author(s): EGJ Moorington
Implementation Notes
--------------------
**Software and Dependencies:**
* Adafruit CircuitPython firmware for the supported boards:
https://circuitpython.org/downloads
"""
# imports
from keypad import Event, EventQueue
try:
from supervisor import ticks_ms # type: ignore
except ImportError:
from time import time
start_time = time()
def ticks_ms() -> int:
return int((time() - start_time + 536805.912) * 1000) & _TICKS_MAX
try:
from typing import Callable, Literal, TypeAlias, Union # noqa: F401
except ImportError:
pass
__version__ = "0.0.0+auto.0"
__repo__ = "https://github.com/EGJ-Moorington/CircuitPython_Button_Handler.git"
_TICKS_PERIOD = 1 << 29
_TICKS_MAX = _TICKS_PERIOD - 1
def timestamp_diff(time1: int, time2: int) -> int:
"""
Compute the difference between two ticks values,
assuming that they are within 2\\ :sup:`28` ticks.
:param int time1: The minuend of the time difference, in milliseconds.
:param int time2: The subtrahend of the time difference, in milliseconds.
:return int: The difference between the two ticks values, in milliseconds.
"""
diff = (time1 - time2) & _TICKS_MAX
return diff
class ButtonInitConfig:
"""Holds configuration values to pass when a :class:`ButtonHandler` object is initialised."""
def __init__(
self,
enable_multi_press: bool = True,
multi_press_interval: float = 175,
long_press_threshold: float = 1000,
max_multi_press: int = 2,
) -> None:
"""
:param bool enable_multi_press: Sets :attr:`.enable_multi_press`
(whether to track multi-presses).
:param float multi_press_interval: Sets :attr:`.multi_press_interval`
(the time frame within which two presses should occur to count as a multi-press).
:param float long_press_threshold: Sets :attr:`.long_press_threshold`
(the minimum length of a press to count as a long press).
:param int max_multi_press: Sets :attr:`.max_multi_press`
(the maximum amount of presses a multi-press can have).
.. attribute:: enable_multi_press
:type: bool
:value: enable_multi_press = True
Whether to account for the possibility of another short press
following a short press and counting as a multi-press.
If set to false, :meth:`ButtonHandler.update`
returns a short press :class:`ButtonInput` object immediately after a short press.
.. attribute:: long_press_threshold
:type: float
:value: long_press_threshold = 1000
The minimum length of a press to count as a long press,
and the time the button should be pressed before counting as being held down.
.. attribute:: max_multi_press
:type: int
:value: max_multi_press = 2
The maximum amount of button presses that a multi-press can be.
:meth:`ButtonHandler.update` returns the appropriate multi-press :class:`ButtonInput`
object immediaetly after the button has been pressed this many times.
.. attribute:: multi_press_interval
:type: float
:value: multi_press_interval = 175
The time frame from a button release within which
another release should occur to count as a multi-press.
"""
self.enable_multi_press = enable_multi_press
self.long_press_threshold = long_press_threshold
self.max_multi_press = max_multi_press
self.multi_press_interval = multi_press_interval
class Button:
"""Contains information about a single button."""
def __init__(
self, button_number: int = 0, config: ButtonInitConfig = ButtonInitConfig()
) -> None:
"""
:param int button_number: Sets :attr:`._button_number`
(the number associated with the button).
:param ButtonInitConfig config: The :class:`ButtonInitConfig` object used to initialise
the button's settings (:attr:`.enable_multi_press`, :attr:`.long_press_threshold`,
:attr:`.max_multi_press` and :attr:`.multi_press_interval`).
:raise ValueError: if *button_number* is smaller than 0.
.. attribute:: enable_multi_press
:type: bool
:value: config.enable_multi_press = True
Whether to account for the possibility of another short press
following a short press and counting that as a multi-press. If set to false,
:meth:`ButtonHandler.update` returns a short press :class:`ButtonInput`
object immediately after a short press.
.. attribute:: long_press_threshold
:type: float
:value: config.long_press_threshold = 1000
The minimum length of a press to count as a long press,
and the time the button should be pressed before counting as being held down.
.. attribute:: max_multi_press
:type: int
:value: config.max_multi_press = 2
The maximum amount of button presses that a multi-press can be.
:meth:`ButtonHandler.update` returns the appropriate multi-press :class:`ButtonInput`
object immediaetly after the button has been pressed this many times.
.. attribute:: multi_press_interval
:type: float
:value: config.multi_press_interval = 175
The time frame from a button release within which
another release should occur to count as a multi-press.
.. caution:: Attributes with a *leading underscore (_)* are meant for **internal use only**,
and accessing them may cause **unexpected behaviour**. Please consider accessing
a *property* (if available) instead.
.. attribute:: _button_number
:type: int
:value: button_number = 0
The index number associated with the button.
*Consider using* :attr:`.button_number` *instead*.
.. attribute:: _is_holding
:type: bool
:value: False
Whether the button has been held down for at least the time specified
by :attr:`.long_press_threshold`. *Consider using* :attr:`.is_holding` *instead*.
.. attribute:: _is_pressed
:type: bool
:value: False
Whether the button is currently pressed.
*Consider using* :attr:`.is_pressed` *instead*.
.. attribute:: _last_press_time
:type: float | None
:value: None
The time (in miliseconds, tracked by :meth:`supervisor.ticks_ms`) that has passed since
the start of the previous press of a multi-press. It is set to :type:`None`
after the time specified by :attr:`.multi_press_interval` has passed.
.. attribute:: _press_count
:type: int
:value: 0
The amount of times the button has been pressed since the last
multi-press ended. It is set to 0 if the time set
by :attr:`.multi_press_interval` passes after a short press.
.. attribute:: _press_start_time
:type: float
:value: ticks_ms()
The time (in milliseconds, tracked by :meth:`supervisor.ticks_ms`)
at which the last button press began.
"""
if button_number < 0:
raise ValueError("button_number must be non-negative.")
self._button_number = button_number
self.enable_multi_press = config.enable_multi_press
self.long_press_threshold = config.long_press_threshold
self.max_multi_press = config.max_multi_press
self.multi_press_interval = config.multi_press_interval
self._last_press_time = None
self._press_count = 0
self._press_start_time = ticks_ms()
self._is_holding = False
self._is_pressed = False
@property
def button_number(self):
"""
The index number associated with the button.
:type: int
"""
return self._button_number
@property
def is_holding(self):
"""
Whether the button has been held down for at least the time
specified by :attr:`.long_press_threshold`.
:type: bool
"""
return self._is_holding
@property
def is_pressed(self):
"""
Whether the button is currently pressed.
:type: bool
"""
return self._is_pressed
def _check_multi_press_timeout(self, current_time: int) -> Union[int, None]:
"""
.. caution:: Methods with a *leading underscore (_)* are meant for **internal use only**,
and calling them may cause **unexpected behaviour**. Please refrain from using them.
Check whether a multi-press has ended.
If it has, return the amount of times the button was pressed in that multi-press.
:param int current_time: The current time, provided by :meth:`supervisor.ticks_ms`.
:return: The amount of times the button was pressed in a multi-press,
if a multi-press has ended.
:rtype: int or None
"""
if (
self._press_count > 0
and not self._is_pressed
and timestamp_diff(current_time, self._last_press_time) > self.multi_press_interval
):
press_count = self._press_count
self._last_press_time = None
self._press_count = 0
return press_count
return None
def _is_held(self, current_time: int) -> bool:
"""
.. caution:: Methods with a *leading underscore (_)* are meant for **internal use only**,
and calling them may cause **unexpected behaviour**. Please refrain from using them.
Check whether the button has been held down for at least
the time specified by :attr:`.long_press_threshold`.
:param int current_time: The current time, provided by :meth:`supervisor.ticks_ms`.
:return: Whether the button just began being held down.
:rtype: bool
"""
if (
not self._is_holding
and self._is_pressed
and timestamp_diff(current_time, self._press_start_time) >= self.long_press_threshold
):
self._is_holding = True
return True
return False
class ButtonInput:
"""Defines a button's input's characteristics."""
SHORT_PRESS = 1
DOUBLE_PRESS = 2
HOLD = "H"
LONG_PRESS = "L"
def __init__(
self,
action: Union[int, str],
button_number: int = 0,
callback: Callable[[], None] = lambda: None,
timestamp: int = 0,
) -> None:
"""
:param InputAction action: Sets :attr:`action` (the action associated with the input).
:param int button_number: Sets :attr:`button_number`
(the number of the button associated with the input).
:param Callable[[], None] callback: Sets :attr:`callback` (the callback associated
with the input).
:param int timestamp: Sets :attr:`timestamp` (the time at which the input was performed).
.. type:: InputAction
:canonical: int | str
Represents the action the :class:`ButtonInput` object represents.
Using a constant defined by :class:`ButtonInput` when available is recommended.
To represent a multi-press, use the number of presses in that multi-press.
Available constants are :const:`SHORT_PRESS`, :const:`DOUBLE_PRESS`,
:const:`HOLD` and :const:`LONG_PRESS`.
.. attribute:: button_number
:type: int
:value: 0
The index number of the button associated with the input.
.. attribute:: callback
:type: Callable[[], None]
:value: lambda: None
The function to call when the input is detected
and returned by :meth:`ButtonHandler.update`.
.. attribute:: timestamp
:type: int
:value: 0
The timestamp (in milliseconds, provided by :meth:`supervisor.ticks_ms`)
at which the input was performed.
.. warning:: Variables written in *upper case with underscores* are constants and
should not be modified. Doing so may cause **unexpected behaviour**.
.. data:: SHORT_PRESS
:type: int
:value: 1
Represents a short press to pass as an argument to
parameter `action` in :class:`ButtonInput`.
.. data:: DOUBLE_PRESS
:type: int
:value: 2
Represents a double press to pass as an argument to
parameter `action` in :class:`ButtonInput`.
.. data:: HOLD
:type: str
:value: "H"
Represents a hold action to pass as an argument to
parameter `action` in :class:`ButtonInput`.
.. data:: LONG_PRESS
:type: str
:value: "L"
Represents a long press to pass as an argument to
parameter `action` in :class:`ButtonInput`.
.. caution:: Attributes with a *leading underscore (_)* are meant for
**internal use only**, and accessing them may cause **unexpected behaviour**.
Please consider accessing a *property* (if available) instead.
.. attribute:: _action
:type: InputAction
:value: action
The action associated with the input. *Consider accessing* :attr:`action` *instead*.
"""
self.action = action
self.button_number = button_number
self.callback = callback
self.timestamp = timestamp
@property
def action(self):
"""
The action associated with the input.
:type: InputAction
:param InputAction action: The action associated with the input.
:raise ValueError: if *action* is not a valid action. Valid actions are
:const:`SHORT_PRESS`, :const:`DOUBLE_PRESS`, :const:`HOLD`, :const:`LONG_PRESS`
and any :type:`int` bigger than 0.
"""
return self._action
@action.setter
def action(self, action: Union[int, str]):
if action in {ButtonInput.LONG_PRESS, ButtonInput.HOLD}:
self._action = action
return
try:
if not isinstance(action, int):
raise ValueError
if action < 1:
raise ValueError
self._action = action
except ValueError:
raise ValueError(f"Invalid action: {action}.")
def __eq__(self, other: object) -> bool:
"""
.. note:: This method defines the functionality of *the equality operator (==).
Consider using it instead*.
Return whether two :class:`ButtonInput` objects are the same.
True if both :attr:`action` and :attr:`button_number` are the same in both objects.
:param object other: The object to compare the input to.
:return: Whether the two objects are the same.
:rtype: bool
"""
if isinstance(other, ButtonInput):
return self._action == other._action and self.button_number == other.button_number
return False
def __hash__(self) -> int:
"""
.. note:: This method is called by :meth:`hash`. *Consider using it instead*.
Hash a :class:`ButtonInput` object to an :type:`int`.
:return: The hash value of the input.
:rtype: int
.. seealso:: :meth:`__eq__` — two :class:`ButtonInput` objects hash to the same value
if they are equal.
"""
return hash((self.action, self.button_number))
def __str__(self) -> str:
"""
.. note:: This method is called by :meth:`str`. *Consider using it instead*.
Return a concise string representaton of the :class:`ButtonInput` object.
:return: The string representation.
:rtype: str
"""
return f"{self.action} on button {self.button_number}"
class ButtonHandler:
"""Handles different types of button presses."""
def __init__(
self,
event_queue: EventQueue,
callable_inputs: set[ButtonInput],
button_amount: int = 1,
config: dict[int, ButtonInitConfig] = None,
) -> None:
"""
:param keypad.EventQueue event_queue: Sets :attr:`_event_queue`
(the :class:`keypad.EventQueue` object the handler should read events from).
:param set[ButtonInput] callable_inputs: Sets :attr:`callable_inputs`
(the :class:`ButtonInput` objects used to define the callback functions).
:param int button_amount: The amount of buttons scanned by the :mod:`keypad` scanner
that created the *event_queue* parameter's argument :class:`keypad.EventQueue` object.
:param dict[int, ButtonInitConfig] config: A dictionary containing
:class:`ButtonInitConfig` objects used to initialise :class:`Button` objects.
The dictionary's keys should be the index numbers of the target buttons.
For each button that doesn't have a :class:`ButtonInitConfig` attached to it, an object
containing the default values is created.
:raise ValueError: if *button_amount* is smaller than 1, or if it is not an :type:`int`.
.. attribute:: callable_inputs
:type: set[ButtonInput]
:value: callable_inputs
A set of :class:`ButtonInput` objects used
to define which functions to call when a specific input is detected.
.. caution:: Attributes with a *leading underscore (_)* are meant for **internal use only**,
and accessing them may cause **unexpected behaviour**. Please consider accessing
a *property* (if available) instead.
.. attribute:: _event
:type: keypad.Event
:value: Event()
The :class:`keypad.Event` object used to store the next event to handle.
.. attribute:: _event_queue
:type: keypad.EventQueue
:value: event_queue
The :class:`keypad.EventQueue` object the handler should read events from.
"""
if not isinstance(button_amount, int) or button_amount < 1:
raise ValueError("button_amount must be bigger than 0.")
self.callable_inputs = callable_inputs
self._buttons: list[Button] = []
for i in range(button_amount): # Create a Button object for each button to handle
if config:
conf = config.get(i, ButtonInitConfig())
else:
conf = ButtonInitConfig()
self._buttons.append(Button(i, conf))
self._event = Event()
self._event_queue = event_queue
@property
def buttons(self) -> list[Button]:
"""
The :class:`Button` objects that the handler handles.
:return: The list of :class:`Button` objects that the handler handles.
:rtype: list[Button]
"""
return self._buttons
def update(self) -> set[ButtonInput]:
"""
Check if any button ended a multi-press since the last time this method was called,
process the next :class:`keypad.Event` in :attr:`_event_queue`, call all the relevant
callback functions and return a set of the detected :class:`ButtonInput`\\ s.
:return: Returns a set containing all of the detected :class:`ButtonInput`\\ s
:rtype: set[ButtonInput]
"""
inputs = set()
inputs.update(self._handle_buttons())
event = self._event
event_queue = self._event_queue
while event_queue:
event_queue.get_into(event)
input_ = self._handle_event(event)
if input_:
inputs.add(input_)
self._call_callbacks(inputs)
return inputs
def _call_callbacks(self, inputs: set[ButtonInput]) -> None:
"""
.. caution:: Methods with a *leading underscore (_)* are meant for **internal use only**,
and calling them may cause **unexpected behaviour**. Please refrain from using them.
Call the callback function associated with every :class:`ButtonInput` object detected
during execution of :meth:`.update`.
:param set[ButtonInput] inputs: A set containing every input
whose callback is to be called.
"""
for input_ in inputs:
if not input_ in self.callable_inputs:
continue
for callable_input in self.callable_inputs:
if callable_input == input_:
callable_input.callback()
def _handle_buttons(self) -> set[ButtonInput]:
"""
.. caution:: Methods with a *leading underscore (_)* are meant for **internal use only**,
and calling them may cause **unexpected behaviour**. Please refrain from using them.
Check if any button began being held down since the last time this mehod was called
and if any multi-press ended, and return every detected :class:`ButtonInput`.
:return: A set containing every detected :class:`ButtonInput`.
:rtype: set[ButtonInput]
"""
inputs = set()
current_time = ticks_ms()
for button in self._buttons:
if button._is_held(current_time):
inputs.add(
ButtonInput(ButtonInput.HOLD, button.button_number, timestamp=current_time)
)
else:
num = button._check_multi_press_timeout(current_time)
if num:
inputs.add(ButtonInput(num, button.button_number, timestamp=current_time))
return inputs
def _handle_event(self, event: Event) -> Union[ButtonInput, None]:
"""
.. caution:: Methods with a *leading underscore (_)* are meant for **internal use only**,
and calling them may cause **unexpected behaviour**. Please refrain from using them.
Process a :class:`keypad.Event` and return a :class:`ButtonInput` based on it.
:param keypad.Event event: The :class:`keypad.Event` object to process.
:return: The detected :class:`ButtonInput`, if any.
:rtype: ButtonInput or None
"""
button = self._buttons[event.key_number]
if event.pressed: # Button just pressed
button._is_pressed = True
button._press_start_time = event.timestamp
button._last_press_time = event.timestamp
button._press_count += 1
else: # Button just released
button._is_pressed = False
if (
timestamp_diff(event.timestamp, button._press_start_time)
< button.long_press_threshold
): # Short press
if not button.enable_multi_press:
input_ = ButtonInput(
ButtonInput.SHORT_PRESS, event.key_number, timestamp=event.timestamp
)
elif button._press_count == button.max_multi_press:
input_ = ButtonInput(
button.max_multi_press,
event.key_number,
timestamp=event.timestamp,
)
else: # More short presses could follow
return None
else:
input_ = ButtonInput(
ButtonInput.LONG_PRESS, event.key_number, timestamp=event.timestamp
)
button._is_holding = False
button._last_press_time = None
button._press_count = 0
return input_