diff --git a/.gitignore b/.gitignore index 4ff097d..c56620e 100644 --- a/.gitignore +++ b/.gitignore @@ -26,7 +26,7 @@ *.mpy # Python-specific files -__pycache__ +**/__pycache__ *.pyc # Sphinx build-specific files @@ -35,6 +35,9 @@ _build # Pre-commit related caches .ruff_cache +# Pytest related caches +.pytest_cache + # This file results from running `pip -e install .` in a local repository *.egg-info diff --git a/button_handler.py b/button_handler.py index 93a1643..228a1e9 100644 --- a/button_handler.py +++ b/button_handler.py @@ -23,12 +23,8 @@ """ # imports -from keypad import Event +from keypad import Event, EventQueue -try: - from keypad import EventQueue -except ImportError: - from keypad import _EventQueue as EventQueue # noqa: F401 try: from supervisor import ticks_ms # type: ignore except ImportError: @@ -37,7 +33,7 @@ start_time = time() def ticks_ms() -> int: - ((time() - start_time + 536.805, 912) * 1000) & _TICKS_MAX + return int((time() - start_time + 536805.912) * 1000) & _TICKS_MAX try: @@ -45,7 +41,7 @@ def ticks_ms() -> int: except ImportError: pass -__version__ = "2.0.0-beta.1" +__version__ = "2.0.0-beta.2" __repo__ = "https://github.com/EGJ-Moorington/CircuitPython_Button_Handler.git" _TICKS_PERIOD = 1 << 29 @@ -55,7 +51,7 @@ def ticks_ms() -> int: def timestamp_diff(time1: int, time2: int) -> int: """ Compute the difference between two ticks values, - assuming that they are within 2\ :sup:`28` ticks. + 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. @@ -209,7 +205,7 @@ def __init__( .. attribute:: _press_start_time :type: float - :value: 0 + :value: ticks_ms() The time (in milliseconds, tracked by :meth:`supervisor.ticks_ms`) at which the last button press began. @@ -224,7 +220,7 @@ def __init__( self._last_press_time = None self._press_count = 0 - self._press_start_time = 0 + self._press_start_time = ticks_ms() self._is_holding = False self._is_pressed = False @@ -446,7 +442,7 @@ def __init__( event_queue: EventQueue, callable_inputs: set[ButtonInput], button_amount: int = 1, - config: dict[int, ButtonInitConfig] = {}, + config: dict[int, ButtonInitConfig] = None, ) -> None: """ :param keypad.EventQueue event_queue: Sets :attr:`_event_queue` @@ -460,7 +456,7 @@ def __init__( 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. + :raise ValueError: if *button_amount* is smaller than 1, or if it is not an :type:`int`.. .. attribute:: callable_inputs :type: set[ButtonInput] @@ -485,14 +481,18 @@ def __init__( The :class:`keypad.EventQueue` object the handler should read events from. """ - if button_amount < 1: + 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 - self._buttons.append(Button(i, config.get(i, ButtonInitConfig()))) + if config: + conf = config.get(i, ButtonInitConfig()) + else: + conf = ButtonInitConfig() + self._buttons.append(Button(i, conf)) self._event = Event() self._event_queue = event_queue @@ -511,9 +511,9 @@ 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. + callback functions and return a set of the detected :class:`ButtonInput`\\ s. - :return: Returns a set containing all of the detected :class:`ButtonInput`\ s + :return: Returns a set containing all of the detected :class:`ButtonInput`\\ s :rtype: set[ButtonInput] """ inputs = set() @@ -528,7 +528,7 @@ def update(self) -> set[ButtonInput]: if input_: inputs.add(input_) - self._call_callbacks() + self._call_callbacks(inputs) return inputs def _call_callbacks(self, inputs: set[ButtonInput]) -> None: @@ -590,8 +590,7 @@ def _handle_event(self, event: Event) -> Union[ButtonInput, None]: if event.pressed: # Button just pressed button._is_pressed = True button._press_start_time = event.timestamp - if button._press_count < button.max_multi_press: - button._last_press_time = event.timestamp + button._last_press_time = event.timestamp button._press_count += 1 else: # Button just released diff --git a/tests/test_button_handler.py b/tests/test_button_handler.py new file mode 100644 index 0000000..815d581 --- /dev/null +++ b/tests/test_button_handler.py @@ -0,0 +1,290 @@ +# SPDX-FileCopyrightText: 2017 Scott Shawcroft, written for Adafruit Industries +# SPDX-FileCopyrightText: Copyright (c) 2024 EGJ Moorington +# +# SPDX-License-Identifier: MIT +import sys + +import pytest + +sys.path.append(".") +from button_handler import * + + +class MockEvent(Event): + def __init__( + self, key_number: int = 0, pressed: bool = True, timestamp: int = ticks_ms() + ) -> None: + super().__init__(key_number, pressed) + self.timestamp = timestamp + + +class MockEventQueue(EventQueue): + def __init__(self, max_events): + super().__init__(max_events) + + def get_into(self, event: MockEvent) -> bool: + if not self._events: + return False + next_event = self._events.popleft() + event._key_number = next_event._key_number + event._pressed = next_event._pressed + event.timestamp = next_event.timestamp + return True + + def keypad_eventqueue_record(self, key_number, current, time): + if len(self._events) == self._events.maxlen: + self._overflowed = True + else: + self._events.append(MockEvent(key_number, current, time)) + + +class deque: + def __init__(self, queue: list, maxlen: int): + self.queue = queue + self.maxlen = maxlen + + def popleft(self): + return self.queue.pop(0) + + def __len__(self): + return len(self.queue) + + def append(self, item): + self.queue.append(item) + + +call_amount = 0 + + +def callback(): + global call_amount # noqa: PLW0603 + call_amount += 1 + + +@pytest.fixture +def event_queue() -> MockEventQueue: + return MockEventQueue(max_events=64) + + +@pytest.fixture +def config() -> ButtonInitConfig: + return ButtonInitConfig(False, 200, max_multi_press=4) + + +@pytest.fixture +def time() -> int: + return ticks_ms() + + +@pytest.fixture +def button(config) -> Button: + return Button(config=config) + + +@pytest.fixture +def input_() -> ButtonInput: + return ButtonInput("SHORT_PRESS", 3, callback, timestamp=time) + + +@pytest.fixture +def inputs(input_) -> set[ButtonInput]: + return {input_, ButtonInput("LONG_PRESS", callback=callback)} + + +@pytest.fixture +def button_handler(event_queue, config, inputs) -> ButtonHandler: + return ButtonHandler(event_queue, inputs, 4, {1: config}) + + +def test_timestamp_diff(time): + time2 = time + 10000 + assert timestamp_diff(time2, time) == 10000 + time2 = time + 90000 + assert timestamp_diff(time2, time) == 90000 + + +class TestButtonInitConfig: + def test_init(self, config): + assert config.enable_multi_press == False + assert config.multi_press_interval == 200 + assert config.long_press_threshold == 1000 + assert config.max_multi_press == 4 + + +class TestButton: + def test_init(self, button): + assert button.button_number == 0 + assert button.enable_multi_press == False + assert button.long_press_threshold == 1000 + assert button.max_multi_press == 4 + assert button.multi_press_interval == 200 + + assert button.button_number == button._button_number + assert button.is_holding == button._is_holding + assert button.is_pressed == button._is_pressed + + with pytest.raises(ValueError): + button = Button(-1) + + def test__check_multi_press_timeout(self, time, button: Button): + assert button._check_multi_press_timeout(time) == None + button._press_count += 2 + button._last_press_time = time - button.multi_press_interval * 2 + assert button._check_multi_press_timeout(time) == 2 + + def test__is_held(self, time, button: Button): + button._is_pressed = True + assert button._is_held(time + 250) == False + button._press_start_time = time - button.long_press_threshold * 2 + assert button._is_held(time) == True + + +class TestButtonInput: + def test_init(self, input_): + assert input_.action == "SHORT_PRESS" + assert input_.button_number == 3 + assert input_.callback() == None + assert input_.timestamp == time + + with pytest.raises(ValueError): + input_.action = "0_MULTI_PRESS" + + def test_valid_action(self, input_): + assert input_._action == input_.action + input_.action = "LONG_PRESS" + assert input_.action == "LONG_PRESS" + input_.action = "DOUBLE_PRESS" + assert input_.action == "2_MULTI_PRESS" + input_.action = "1_MULTI_PRESS" + assert input_.action == "SHORT_PRESS" + input_.action = "3_MULTI_PRESS" + assert input_.action == "3_MULTI_PRESS" + + @pytest.mark.parametrize( + "action", + { + "0_MULTI_PRESS", + "_MULTI_PRESS", + "w_MULTI_PRESS", + "_MULTI_PRESS_", + "-1_MULTI_PRESS", + "3.0_MULTI_PRESS", + }, + ) + def test_invalid_action(self, input_, action): + with pytest.raises(ValueError): + input_.action = action + + def test_dunder(self, input_: ButtonInput, time): + assert input_ == ButtonInput("SHORT_PRESS", 3, timestamp=time * 2) + + assert hash(input_) == hash((input_.action, input_.button_number)) + + assert str(input_) == "SHORT_PRESS on button 3" + + +class TestButtonHandler: + def sim_press(self, button: Button, handler: ButtonHandler, press, press_count=1): + queue = handler._event_queue + match press: + case "SHORT_PRESS": + length = button.long_press_threshold - 825 + case "LONG_PRESS": + length = button.long_press_threshold + 100 + start_time = ticks_ms() - length + + queue.keypad_eventqueue_record(button.button_number, True, start_time) + start = handler.update() + button._press_count = press_count + queue.keypad_eventqueue_record(button.button_number, False, ticks_ms()) + return start + + def test_init(self, button_handler: ButtonHandler, input_, config: ButtonInitConfig): + assert input_ in button_handler.callable_inputs + assert len(button_handler.buttons) == 4 + assert button_handler.buttons[1].max_multi_press == config.max_multi_press + + @pytest.mark.parametrize("amount", {0, 1.2, -1}) + def test_invalid_button_amount(self, amount, event_queue): + with pytest.raises(ValueError): + ButtonHandler(event_queue, set(), button_amount=amount) + + def test__call_callbacks(self, inputs, button_handler: ButtonHandler): + global call_amount # noqa: PLW0603 + call_amount = 0 + inputs.add(ButtonInput("HOLD")) + button_handler._call_callbacks(inputs) + assert call_amount == 2 + + def test__handle_buttons(self, button_handler: ButtonHandler, time): + inputs = button_handler._handle_buttons() + assert inputs == set() + + button = button_handler.buttons[2] + button._is_pressed = True + button._press_start_time = time - button.long_press_threshold * 2 + inputs = button_handler._handle_buttons() + assert inputs == {ButtonInput("HOLD", 2)} + + button = button_handler.buttons[3] + button._press_count = 3 + button._last_press_time = time - button.multi_press_interval * 2 + inputs = button_handler._handle_buttons() + assert inputs == {ButtonInput("3_MULTI_PRESS", 3)} + + def test__handle_event(self, time, button_handler: ButtonHandler): + button = button_handler.buttons[1] + button._button_number = 1 + + event = MockEvent(1, True, time) + button._last_press_time = 0 + button_handler._handle_event(event) + assert button.is_pressed + assert button._press_start_time == event.timestamp + assert button._last_press_time == event.timestamp + assert button._press_count == 1 + + button._press_start_time = time - button.long_press_threshold // 2 + button.enable_multi_press = False + event = MockEvent(1, False, time) + assert button_handler._handle_event(event) == ButtonInput("SHORT_PRESS", 1) + + button.enable_multi_press = True + assert button_handler._handle_event(event) == None + + button._press_count = 4 + assert button_handler._handle_event(event) == ButtonInput("4_MULTI_PRESS", 1) + + button._press_start_time = time - button.long_press_threshold * 2 + assert button_handler._handle_event(event) == ButtonInput("LONG_PRESS", 1) + + assert button._last_press_time == None + assert button._press_count == 0 + + def test_update(self, button_handler: ButtonHandler, time): + queue: MockEventQueue = button_handler._event_queue + button = button_handler.buttons[2] + + # Incomplete multi press + timeout + assert self.sim_press(button, button_handler, "SHORT_PRESS") == set() + input_set = button_handler.update() + assert input_set == set() + while input_set == set() and timestamp_diff(ticks_ms(), time) < 100: + input_set = button_handler.update() + assert input_set.pop() == ButtonInput("SHORT_PRESS", 2) + + # Multi press disabled + button = button_handler.buttons[1] + assert self.sim_press(button, button_handler, "SHORT_PRESS", 4) == set() + assert button_handler.update().pop() == ButtonInput("SHORT_PRESS", 1) + + # Finish max multi press + self.sim_press(button, button_handler, "SHORT_PRESS") + button._press_count = 4 + button.enable_multi_press = True + assert button_handler.update().pop() == ButtonInput("4_MULTI_PRESS", 1) + + # Long press + button._press_start_time = time - button.long_press_threshold * 2 + queue.keypad_eventqueue_record(1, False, time) + assert button_handler.update().pop() == ButtonInput("LONG_PRESS", 1)