diff --git a/adafruit_debouncer.py b/adafruit_debouncer.py index dda0eec..8ff911a 100644 --- a/adafruit_debouncer.py +++ b/adafruit_debouncer.py @@ -23,9 +23,9 @@ `adafruit_debouncer` ==================================================== -Debounces an arbitrary predicate function (typically created as a lambda) of 0 arguments. -Since a very common use is debouncing a digital input pin, the initializer accepts a pin number -instead of a lambda. +Debounces an arbitrary predicate function (typically created as a lambda) of 0 +arguments. Since a very common use is debouncing a digital input pin, the +initializer accepts a DigitalInOut object instead of a lambda. * Author(s): Dave Astels @@ -34,6 +34,16 @@ **Hardware:** +Not all hardware / CircuitPython combinations are capable of running the +debouncer correctly for an extended length of time. If this line works +on your microcontroller, then the debouncer should work forever: + +``from time import monotonic_ns`` + +If it gives an ImportError, then the time values available in Python become +less accurate over the days, and the debouncer will take longer to react to +button presses. + **Software and Dependencies:** * Adafruit CircuitPython firmware for the supported boards: @@ -52,6 +62,15 @@ _UNSTABLE_STATE = const(0x02) _CHANGED_STATE = const(0x04) +# Find out whether the current CircuitPython supports time.monotonic_ns(), +# which doesn't have the accuracy limitation. +if hasattr(time, "monotonic_ns"): + TICKS_PER_SEC = 1_000_000_000 + MONOTONIC_TICKS = time.monotonic_ns +else: + TICKS_PER_SEC = 1 + MONOTONIC_TICKS = time.monotonic + class Debouncer: """Debounce an input pin or an arbitrary predicate""" @@ -68,10 +87,13 @@ def __init__(self, io_or_predicate, interval=0.010): self.function = io_or_predicate if self.function(): self._set_state(_DEBOUNCED_STATE | _UNSTABLE_STATE) - self.previous_time = 0 - self.interval = interval - self._previous_state_duration = 0 - self._state_changed_time = 0 + self._last_bounce_ticks = 0 + self._last_duration_ticks = 0 + self._state_changed_ticks = 0 + + # Could use the .interval setter, but pylint prefers that we explicitly + # set the real underlying attribute: + self._interval_ticks = interval * TICKS_PER_SEC def _set_state(self, bits): self.state |= bits @@ -87,20 +109,29 @@ def _get_state(self, bits): def update(self): """Update the debouncer state. MUST be called frequently""" - now = time.monotonic() + now_ticks = MONOTONIC_TICKS() self._unset_state(_CHANGED_STATE) current_state = self.function() if current_state != self._get_state(_UNSTABLE_STATE): - self.previous_time = now + self._last_bounce_ticks = now_ticks self._toggle_state(_UNSTABLE_STATE) else: - if now - self.previous_time >= self.interval: + if now_ticks - self._last_bounce_ticks >= self._interval_ticks: if current_state != self._get_state(_DEBOUNCED_STATE): - self.previous_time = now + self._last_bounce_ticks = now_ticks self._toggle_state(_DEBOUNCED_STATE) self._set_state(_CHANGED_STATE) - self._previous_state_duration = now - self._state_changed_time - self._state_changed_time = now + self._last_duration_ticks = now_ticks - self._state_changed_ticks + self._state_changed_ticks = now_ticks + + @property + def interval(self): + """The debounce delay, in seconds""" + return self._interval_ticks / TICKS_PER_SEC + + @interval.setter + def interval(self, new_interval_s): + self._interval_ticks = new_interval_s * TICKS_PER_SEC @property def value(self): @@ -121,10 +152,10 @@ def fell(self): @property def last_duration(self): - """Return the amount of time the state was stable prior to the most recent transition.""" - return self._previous_state_duration + """Return the number of seconds the state was stable prior to the most recent transition.""" + return self._last_duration_ticks / TICKS_PER_SEC @property def current_duration(self): - """Return the time since the most recent transition.""" - return time.monotonic() - self._state_changed_time + """Return the number of seconds since the most recent transition.""" + return (MONOTONIC_TICKS() - self._state_changed_ticks) / TICKS_PER_SEC diff --git a/tests/tests.py b/tests/tests.py new file mode 100644 index 0000000..d90d56e --- /dev/null +++ b/tests/tests.py @@ -0,0 +1,151 @@ +""" +How to use this test file: + +Copy adafruit_debouncer's dependencies to lib/ on your circuitpython device. +Copy adafruit_debouncer.py to / on the device +Copy this tests.py file to /main.py on the device +Connect to the serial terminal (e.g. sudo screen /dev/ttyACM0 115200) +Press Ctrl-D, if needed to start the tests running +""" +import sys +import time +import adafruit_debouncer + + +def _true(): + return True + + +def _false(): + return False + + +def assertEqual(a, b): + assert a == b, "Want %r, got %r" % (a, b) + + +def test_back_and_forth(): + # Start false + db = adafruit_debouncer.Debouncer(_false) + assertEqual(db.value, False) + + # Set the raw state to true, update, and make sure the debounced + # state has not changed yet: + db.function = _true + db.update() + assertEqual(db.value, False) + assert not db.last_duration, "There was no previous interval??" + + # Sleep longer than the debounce interval, so state can change: + time.sleep(0.02) + db.update() + assert db.last_duration # is actually duration between powerup and now + assertEqual(db.value, True) + assertEqual(db.rose, True) + assertEqual(db.fell, False) + # Duration since last change has only been long enough to run these + # asserts, which should be well under 1/10 second + assert db.current_duration < 0.1, "Unit error? %d" % db.current_duration + + # Set raw state back to false, make sure it's not instantly reflected, + # then wait and make sure it IS reflected after the interval has passed. + db.function = _false + db.update() + assertEqual(db.value, True) + assertEqual(db.fell, False) + assertEqual(db.rose, False) + time.sleep(0.02) + assert 0.019 < db.current_duration <= 1, ( + "Unit error? sleep .02 -> duration %d" % db.current_duration + ) + db.update() + assertEqual(db.value, False) + assertEqual(db.rose, False) + assertEqual(db.fell, True) + + assert 0 < db.current_duration <= 0.1, ( + "Unit error? time to run asserts %d" % db.current_duration + ) + assert 0 < db.last_duration < 0.1, ( + "Unit error? Last dur should be ~.02, is %d" % db.last_duration + ) + + +def test_interval_is_the_same(): + db = adafruit_debouncer.Debouncer(_false, interval=0.25) + assertEqual(db.value, False) + db.update() + db.function = _true + db.update() + + time.sleep(0.1) # longer than default interval + db.update() + assertEqual(db.value, False) + + time.sleep(0.2) # 0.1 + 0.2 > 0.25 + db.update() + assertEqual(db.value, True) + assertEqual(db.rose, True) + assertEqual(db.interval, 0.25) + + +def test_setting_interval(): + # Check that setting the interval does change the time the debouncer waits + db = adafruit_debouncer.Debouncer(_false, interval=0.01) + db.update() + + # set the interval to a longer time, sleep for a time between + # the two interval settings, and assert that the value hasn't changed. + + db.function = _true + db.interval = 0.2 + db.update() + assert db.interval - 0.2 < 0.00001, "interval is not consistent" + time.sleep(0.11) + db.update() + + assertEqual(db.value, False) + assertEqual(db.rose, False) + assertEqual(db.fell, False) + + # and then once the whole time has passed make sure it did change + time.sleep(0.11) + db.update() + assertEqual(db.value, True) + assertEqual(db.rose, True) + assertEqual(db.fell, False) + + +def run(): + passes = 0 + fails = 0 + for name, test in locals().items(): + if name.startswith("test_") and callable(test): + try: + print() + print(name) + test() + print("PASS") + passes += 1 + except Exception as e: + sys.print_exception(e) + print("FAIL") + fails += 1 + + print(passes, "passed,", fails, "failed") + if passes and not fails: + print( + r""" + ________ +< YATTA! > + -------- + \ ^__^ + \ (oo)\_______ + (__)\ )\/\ + ||----w | + || ||""" + ) + + +if __name__ == "__main__": + run()