Add files via upload

This commit is contained in:
Seo Chaegeon authored and GitHub committed 2026-01-08 16:05:05 +09:00
1 parent 875dc44682
commit e3bb87d4e7
15 files changed
+3623 -1

No files matched your search

+164
View File
@@ -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