Files
pi-multifx-pedal/src/ui/footswitch.py
T

251 lines
9.1 KiB
Python

"""Footswitch controller — debounced GPIO input for stomp switches.
Handles multiple momentary footswitches with debouncing,
long-press detection, and mode switching.
Typical layout:
[FS1] Preset Up / Tap Tempo [FS2] Preset Down / Hold for Tuner
[FS3] Bypass [FS4] Bank Up
[FS5] FX Select [FS6] Tap Tempo
"""
from __future__ import annotations
import logging
import threading
import time
from dataclasses import dataclass, field
from enum import Enum
from typing import Callable, Optional
logger = logging.getLogger(__name__)
DEBOUNCE_MS = 20 # Debounce window
LONG_PRESS_MS = 500 # Long press threshold
POLL_INTERVAL_S = 0.005 # 5ms poll for responsiveness
class SwitchAction(Enum):
"""Actions triggered by footswitch events."""
PRESET_UP = "preset_up"
PRESET_DOWN = "preset_down"
BANK_UP = "bank_up"
BANK_DOWN = "bank_down"
BYPASS = "bypass"
TAP_TEMPO = "tap_tempo"
TUNER = "tuner"
FX_PREV = "fx_prev"
FX_NEXT = "fx_next"
EXPRESSION_TOGGLE = "expression_toggle"
MIDI_LEARN = "midi_learn"
SNAPSHOT_SAVE = "snapshot_save"
@dataclass
class FootSwitch:
"""State of a single footswitch."""
gpio_pin: int
action_default: SwitchAction
action_long_press: Optional[SwitchAction] = None
active_low: bool = True
def _import_gpio():
"""Import RPi.GPIO gracefully — returns None on non-RPi platforms."""
try:
import RPi.GPIO as GPIO
return GPIO
except (ImportError, RuntimeError):
return None
class FootswitchController:
"""Debounced footswitch input monitor.
In production, this reads RPi.GPIO. In testing/dev,
it uses a virtual pin store that can be driven by simulate_press.
"""
def __init__(self, switches: list[FootSwitch] | None = None):
self._switches = switches or self._default_layout()
self._callbacks: dict[SwitchAction, list[Callable]] = {}
self._running = False
self._thread: Optional[threading.Thread] = None
self._gpio = _import_gpio()
# Per-pin state tracking — maps gpio_pin -> pin state
self._pin_tracker: dict[int, _PinState] = {}
for sw in self._switches:
self._pin_tracker[sw.gpio_pin] = _PinState()
def _default_layout(self) -> list[FootSwitch]:
"""4-switch default layout."""
return [
FootSwitch(17, SwitchAction.PRESET_UP, SwitchAction.TAP_TEMPO),
FootSwitch(27, SwitchAction.PRESET_DOWN, SwitchAction.TUNER),
FootSwitch(22, SwitchAction.BYPASS, SwitchAction.SNAPSHOT_SAVE),
FootSwitch(23, SwitchAction.BANK_UP, SwitchAction.BANK_DOWN),
]
def register_callback(self, action: SwitchAction, callback: Callable) -> None:
"""Register a callback for a switch action."""
self._callbacks.setdefault(action, []).append(callback)
def _trigger(self, action: SwitchAction) -> None:
"""Fire callbacks for an action."""
for cb in self._callbacks.get(action, []):
try:
cb()
except Exception as e:
logger.error("Switch callback error for %s: %s", action.value, e)
# --- GPIO abstraction layer ---
def _read_pin(self, pin: int) -> bool:
"""Read a GPIO pin.
Returns True = pressed, False = released.
If RPi.GPIO is unavailable, reads from a virtual store (for testing).
"""
if self._gpio:
raw = self._gpio.input(pin)
# Find which switch maps to this pin so we can invert if active_low
for sw in self._switches:
if sw.gpio_pin == pin:
return not raw if sw.active_low else bool(raw)
return bool(raw)
else:
return self._pin_tracker[pin].virtual_level
def _setup_pins(self) -> None:
"""Configure GPIO pins as inputs with pull-up/down."""
if not self._gpio:
logger.info("No RPi.GPIO — running in virtual (test) mode")
return
self._gpio.setmode(self._gpio.BCM)
for sw in self._switches:
pull = self._gpio.PUD_UP if sw.active_low else self._gpio.PUD_DOWN
self._gpio.setup(sw.gpio_pin, self._gpio.IN, pull_up_down=pull)
def _cleanup_pins(self) -> None:
"""Release GPIO pin configuration."""
if self._gpio:
self._gpio.cleanup()
# --- Debounce engine ---
def _poll_loop(self) -> None:
"""Background thread: poll pins with debounce and long-press detection."""
last_logged_state: dict[int, bool] = {}
for sw in self._switches:
last_logged_state[sw.gpio_pin] = False
while self._running:
now_ms = time.monotonic() * 1000
for sw in self._switches:
pin = sw.gpio_pin
tracker = self._pin_tracker[pin]
raw = self._read_pin(pin)
# --- Debounce ---
if raw != tracker.unstable_level:
tracker.unstable_level = raw
tracker.last_change_ms = now_ms
continue # Changed — wait for debounce window
# Stable across debounce window?
elapsed = now_ms - tracker.last_change_ms
if elapsed < DEBOUNCE_MS:
continue # Still within debounce window
# Stable and beyond debounce window — commit stable state
if raw != tracker.stable_level:
tracker.stable_level = raw
logger.debug("Pin %d debounced: %s", pin, "PRESSED" if raw else "released")
if raw: # Just pressed
tracker.press_start_ms = now_ms
else: # Just released — check for short vs long press
press_duration = now_ms - tracker.press_start_ms
if press_duration >= LONG_PRESS_MS and sw.action_long_press:
logger.debug("Pin %d LONG press (%dms) → %s", pin, int(press_duration), sw.action_long_press.value)
self._trigger(sw.action_long_press)
tracker.long_press_handled = True
elif press_duration >= LONG_PRESS_MS and not sw.action_long_press:
# No long-press action mapped — fall through to default
logger.debug("Pin %d long press, no action mapped — triggering default", pin)
self._trigger(sw.action_default)
else:
logger.debug("Pin %d short press (%dms) → %s", pin, int(press_duration), sw.action_default.value)
self._trigger(sw.action_default)
tracker.long_press_handled = False
# If still pressed and past long-press threshold but no release yet
# — don't repeatedly fire, just mark it
if raw and tracker.stable_level and not tracker.long_press_handled:
press_duration = now_ms - tracker.press_start_ms
if press_duration >= LONG_PRESS_MS and sw.action_long_press:
logger.debug("Pin %d LONG press triggered (no release needed)", pin)
self._trigger(sw.action_long_press)
tracker.long_press_handled = True
# Reset long_press_handled when released
if not raw:
tracker.long_press_handled = False
time.sleep(POLL_INTERVAL_S)
# --- Lifecycle ---
def start(self) -> None:
"""Start monitoring footswitches.
In production, starts GPIO monitoring thread.
"""
self._setup_pins()
self._running = True
self._thread = threading.Thread(target=self._poll_loop, daemon=True, name="footswitch-poll")
self._thread.start()
logger.info("Footswitch controller started (%d switches)", len(self._switches))
def stop(self) -> None:
"""Stop monitoring."""
self._running = False
if self._thread:
self._thread.join(timeout=2.0)
self._thread = None
self._cleanup_pins()
logger.info("Footswitch controller stopped")
# --- Testing hooks ---
def simulate_press(self, action: SwitchAction) -> None:
"""Simulate a footswitch press (for testing).
Directly triggers the action without going through GPIO.
"""
logger.debug("SIMULATED press: %s", action.value)
self._trigger(action)
def simulate_gpio_change(self, pin: int, pressed: bool) -> None:
"""Set a virtual GPIO pin level for testing the debounce engine."""
tracker = self._pin_tracker.get(pin)
if tracker:
tracker.virtual_level = pressed
# --- Internal state holder ---
@dataclass
class _PinState:
"""Per-pin debounce state."""
last_change_ms: float = 0.0
press_start_ms: float = 0.0
unstable_level: bool = False
stable_level: bool = False
long_press_handled: bool = False
virtual_level: bool = False # For testing without RPi.GPIO