Add files via upload
This commit is contained in:
1 parent
875dc44682
commit
e3bb87d4e7
15 files changed
+3623
-1
No files matched your search
@@ -0,0 +1,164 @@
|
||||
"""
|
||||
Robust debounce decorator for callbacks like GPIO interrupts.
|
||||
|
||||
Features:
|
||||
- Leading / trailing edge control
|
||||
- Optional max_wait (force-fire) to avoid indefinite deferral
|
||||
- Per-key debounce (e.g., per-pin) for shared handlers
|
||||
- Thread-safe (uses Lock), time.monotonic() for steady-time
|
||||
- Drop-in compatible with previous debounce(wait_sec) usage
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
import time
|
||||
import threading
|
||||
from functools import wraps
|
||||
from typing import Any, Callable, Optional
|
||||
|
||||
|
||||
def debounce(
|
||||
wait_sec: float,
|
||||
*,
|
||||
leading: bool = False,
|
||||
trailing: bool = True,
|
||||
max_wait: Optional[float] = None,
|
||||
key: Optional[Callable[..., Any]] = None,
|
||||
clock: Callable[[], float] = time.monotonic,
|
||||
):
|
||||
"""
|
||||
Debounce a function so that it’s not called too frequently.
|
||||
|
||||
Args:
|
||||
wait_sec: Quiet period length. Calls within this window are coalesced.
|
||||
leading: If True, call immediately on the first call in a burst.
|
||||
trailing: If True, call after the burst “settles” for wait_sec.
|
||||
max_wait: Force a call if this many seconds have elapsed since the
|
||||
last *invocation* (helps for steady streams).
|
||||
key: Per-key debounce. A function that maps (*args, **kwargs) to a
|
||||
hashable key (e.g., lambda pin, *_: pin). If None, a single
|
||||
global bucket is used.
|
||||
clock: Time source; default is time.monotonic().
|
||||
|
||||
Notes:
|
||||
- If both leading and trailing are True:
|
||||
* first call in a burst fires immediately,
|
||||
* further calls delay a trailing fire until wait_sec after the last call,
|
||||
* but a trailing fire is skipped if there were no calls after the leading fire.
|
||||
- If only leading is True => at most one call per burst, immediate.
|
||||
- If only trailing is True => only the last call args are used.
|
||||
- If max_wait is set, a call is forced at most every max_wait seconds.
|
||||
"""
|
||||
if not leading and not trailing:
|
||||
raise ValueError("At least one of leading or trailing must be True.")
|
||||
|
||||
class _Bucket:
|
||||
__slots__ = (
|
||||
"lock", "timer", "last_invoke", "last_call",
|
||||
"pending", "pending_args", "pending_kwargs",
|
||||
)
|
||||
def __init__(self):
|
||||
self.lock = threading.Lock()
|
||||
self.timer: Optional[threading.Timer] = None
|
||||
self.last_invoke: Optional[float] = None # last actual function call time
|
||||
self.last_call: Optional[float] = None # last wrapper call time
|
||||
self.pending: bool = False
|
||||
self.pending_args = ()
|
||||
self.pending_kwargs = {}
|
||||
|
||||
buckets = {}
|
||||
buckets_lock = threading.Lock()
|
||||
|
||||
def _get_bucket(k: Any) -> _Bucket:
|
||||
with buckets_lock:
|
||||
b = buckets.get(k)
|
||||
if b is None:
|
||||
b = _Bucket()
|
||||
buckets[k] = b
|
||||
return b
|
||||
|
||||
def decorator(fn: Callable):
|
||||
@wraps(fn)
|
||||
def wrapper(*args, **kwargs):
|
||||
# derive bucket key
|
||||
k = key(*args, **kwargs) if key else None
|
||||
b = _get_bucket(k)
|
||||
now = clock()
|
||||
|
||||
def _invoke_locked():
|
||||
"""Call the function with the latest pending args/kwargs.
|
||||
b.lock must be held by caller."""
|
||||
b.last_invoke = clock()
|
||||
b.pending = False
|
||||
call_args = b.pending_args
|
||||
call_kwargs = b.pending_kwargs
|
||||
# release the lock during the user call to avoid deadlocks
|
||||
b.lock.release()
|
||||
try:
|
||||
return fn(*call_args, **call_kwargs)
|
||||
finally:
|
||||
b.lock.acquire()
|
||||
|
||||
def _schedule_trailing():
|
||||
"""(Re)schedule trailing call timer from 'now'."""
|
||||
if b.timer:
|
||||
b.timer.cancel()
|
||||
b.timer = threading.Timer(wait_sec, _trailing_fire)
|
||||
b.timer.daemon = True
|
||||
b.timer.start()
|
||||
|
||||
def _trailing_fire():
|
||||
with b.lock:
|
||||
# fire only if something is pending
|
||||
if not b.pending:
|
||||
return
|
||||
# if max_wait is set and we haven't exceeded it yet AND there
|
||||
# were no new calls, still trailing should run because timer fired
|
||||
_invoke_locked()
|
||||
# after trailing fire, clear timer
|
||||
t = b.timer
|
||||
b.timer = None
|
||||
if t:
|
||||
try:
|
||||
t.cancel()
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
with b.lock:
|
||||
b.last_call = now
|
||||
b.pending = True
|
||||
b.pending_args = args
|
||||
b.pending_kwargs = kwargs
|
||||
|
||||
call_now = False
|
||||
|
||||
# Decide leading fire
|
||||
if leading:
|
||||
if b.last_invoke is None:
|
||||
# first ever
|
||||
call_now = True
|
||||
else:
|
||||
# if enough time has passed since last_invoke -> new burst
|
||||
if (now - b.last_invoke) >= wait_sec and (b.timer is None):
|
||||
call_now = True
|
||||
|
||||
# Decide max_wait forced fire (never conflict with call_now)
|
||||
if (not call_now) and max_wait is not None and b.last_invoke is not None:
|
||||
if (now - b.last_invoke) >= max_wait:
|
||||
call_now = True
|
||||
|
||||
result = None
|
||||
if call_now:
|
||||
result = _invoke_locked()
|
||||
# after a leading/max_wait fire, we may still want trailing if more
|
||||
# calls keep arriving; re-arm the trailing timer if trailing is True
|
||||
if trailing:
|
||||
_schedule_trailing()
|
||||
else:
|
||||
# no immediate call; manage trailing
|
||||
if trailing:
|
||||
_schedule_trailing()
|
||||
|
||||
return result
|
||||
|
||||
return wrapper
|
||||
return decorator
|
||||
Reference in new issue
Block a user