Initial scaffold: Pi Multi-FX Pedal with NAM A2, IR cab, multi-FX, MIDI, stomp UI

This commit is contained in:
2026-06-07 23:22:43 -04:00
commit ed29748a62
24 changed files with 6215 additions and 0 deletions
+41
View File
@@ -0,0 +1,41 @@
"""Pi Multi-FX Pedal — Hardware UI layer."""
from src.ui.footswitch import (
DEBOUNCE_MS,
LONG_PRESS_MS,
FootSwitch,
FootswitchController,
SwitchAction,
)
from src.ui.leds import (
LEDAnimation,
LEDConfig,
LEDController,
LEDDriver,
LEDPattern,
)
from src.ui.display import (
DISPLAY_H,
DISPLAY_W,
DisplayController,
DisplayState,
)
__all__ = [
# footswitch
"DEBOUNCE_MS",
"LONG_PRESS_MS",
"FootSwitch",
"FootswitchController",
"SwitchAction",
# LEDs
"LEDAnimation",
"LEDConfig",
"LEDController",
"LEDDriver",
"LEDPattern",
# display
"DISPLAY_H",
"DISPLAY_W",
"DisplayController",
"DisplayState",
]
+302
View File
@@ -0,0 +1,302 @@
"""OLED display manager for the Pi Multi-FX Pedal.
Controls a 128x64 SSD1306 OLED via I2C to show:
- Current preset name, bank, and status
- Bypass status and tuner mode
- Active FX chain with per-block status
- Parameter values on edit
"""
from __future__ import annotations
import logging
from dataclasses import dataclass, field
from typing import Optional
logger = logging.getLogger(__name__)
# Display dimensions
DISPLAY_W = 128
DISPLAY_H = 64
# Layout constants
MARGIN = 2
LINE_H = 10 # 8px font + 2px spacing
HEADER_H = 10 # Top status bar height
FOOTER_Y = 56 # Bottom status line Y
# Font sizes for PIL
FONT_SMALL = 8
FONT_NORMAL = 10
FONT_LARGE = 16
# Tuner display geometry
TUNER_CENTER_X = 64
TUNER_CENTER_Y = 32
TUNER_NOTE_RADIUS = 20
TUNER_NOTE_FONT = 24
TUNER_CENT_FONT = 10
@dataclass
class DisplayState:
"""What to show on the display."""
mode: str = "preset" # preset, tuner, fx_edit, settings
preset_name: str = ""
bank_name: str = ""
bypassed: bool = False
bypass_led_state: bool = False
fx_active: list[str] = field(default_factory=list)
fx_bypass_states: dict[str, bool] = field(default_factory=dict)
tuner_note: str = ""
tuner_cents: int = 0
param_name: str = ""
param_value: float = 0.0
def _import_display_driver():
"""Import the SSD1306 display library gracefully."""
try:
import board
import busio
import adafruit_ssd1306
from PIL import Image, ImageDraw, ImageFont
return (adafruit_ssd1306, Image, ImageDraw, ImageFont)
except (ImportError, NotImplementedError):
return None
class DisplayController:
"""Manages the OLED display.
In production, uses adafruit-circuitpython-ssd1306 over I2C with PIL.
In dev/testing, logs display output.
"""
def __init__(self, i2c_bus: int = 1, i2c_addr: int = 0x3C):
self._i2c_bus = i2c_bus
self._i2c_addr = i2c_addr
self._state = DisplayState()
self._initialized = False
# Hardware handles
self._display = None
self._image: Optional["Image"] = None
self._draw: Optional["ImageDraw"] = None
self._fonts: dict[str, "ImageFont"] = {}
def initialize(self) -> bool:
"""Initialize the OLED display hardware.
Returns False if no display is connected (dev mode).
"""
try:
driver_pkg = _import_display_driver()
if driver_pkg is None:
self._initialized = False
logger.info("Display libraries not available — running in headless mode")
return False
adafruit_ssd1306, Image, ImageDraw, ImageFont = driver_pkg
import board
import busio
i2c = busio.I2C(board.SCL, board.SDA)
self._display = adafruit_ssd1306.SSD1306_I2C(
DISPLAY_W, DISPLAY_H, i2c, addr=self._i2c_addr
)
# Create an image buffer
self._image = Image.new("1", (DISPLAY_W, DISPLAY_H))
self._draw = ImageDraw.Draw(self._image)
# Load default font — use PIL default bitmap font
self._fonts["small"] = ImageFont.load_default()
self._fonts["normal"] = ImageFont.load_default()
self._fonts["large"] = ImageFont.load_default()
self._initialized = True
logger.info("Display initialized (128x64 OLED @ 0x%02x)", self._i2c_addr)
# Show splash
self._splash()
return True
except Exception as e:
logger.info("No display detected — running in headless mode: %s", e)
self._initialized = False
return False
def update(self, state: DisplayState) -> None:
"""Update the display with new state and re-render."""
self._state = state
if not self._initialized:
logger.debug(
"Display [%s]: %s | bypass=%s | FX=%s | tuner=%s %+d",
state.mode,
state.preset_name or state.bank_name or "",
"BYP" if state.bypassed else "ON",
", ".join(state.fx_active) or "",
state.tuner_note or "",
state.tuner_cents,
)
else:
self._render()
def _render(self) -> None:
"""Render current state to the OLED using the PIL buffer."""
if not self._initialized or self._draw is None or self._image is None:
return
draw = self._draw
img = self._image
# Clear the buffer (white background — SSD1306 white=1)
draw.rectangle((0, 0, DISPLAY_W - 1, DISPLAY_H - 1), fill=0, outline=0)
if self._state.mode == "tuner":
self._render_tuner(draw)
elif self._state.mode == "preset":
self._render_preset(draw)
elif self._state.mode == "fx_edit":
self._render_fx_edit(draw)
elif self._state.mode == "settings":
self._render_settings(draw)
else:
self._render_preset(draw)
# Blit to hardware
self._display.image(img)
self._display.show()
# --- Rendering modes ---
def _render_preset(self, draw) -> None:
"""Render preset mode layout."""
s = self._state
font = self._fonts.get("normal")
# Header bar — bank name
bank_text = f"[{s.bank_name}]" if s.bank_name else ""
draw.text((MARGIN, MARGIN), bank_text, fill=255, font=font)
# Bypass indicator top-right
bypass_text = "BYP" if s.bypassed else "ACTIVE"
bypass_x = DISPLAY_W - MARGIN - (len(bypass_text) * 6)
draw.text((bypass_x, MARGIN), bypass_text, fill=255, font=font)
# Preset name — large, centered-ish
preset_y = HEADER_H + 4
draw.text((MARGIN, preset_y), s.preset_name or "", fill=255, font=self._fonts.get("large"))
# Active FX chain (wrap if needed)
fx_y = preset_y + 20
if s.fx_active:
parts = []
for fx in s.fx_active:
bypassed = s.fx_bypass_states.get(fx, False)
if bypassed:
parts.append(f"[{fx}]")
else:
parts.append(fx)
fx_line = " ".join(parts)
# Truncate to fit display width
max_chars = DISPLAY_W // 6
if len(fx_line) > max_chars:
fx_line = fx_line[:max_chars - 3] + "..."
draw.text((MARGIN, fx_y), fx_line, fill=255, font=font)
# Footer — preset number or info
draw.text((MARGIN, FOOTER_Y), s.mode.upper(), fill=255, font=font)
def _render_tuner(self, draw) -> None:
"""Render tuner mode — note name + cents indicator."""
s = self._state
font_normal = self._fonts.get("normal")
# "TUNER" header
draw.text((MARGIN, MARGIN), "TUNER", fill=255, font=font_normal)
# Large note name in center
note = s.tuner_note or "--"
draw.text(
(TUNER_CENTER_X - len(note) * 7, TUNER_CENTER_Y - 8),
note,
fill=255,
font=self._fonts.get("large"),
)
# Cents indicator bar
cents = max(-50, min(50, s.tuner_cents))
bar_center_x = TUNER_CENTER_X
bar_y = TUNER_CENTER_Y + 16
bar_w = 40
bar_h = 4
# Background bar
draw.rectangle(
(bar_center_x - bar_w // 2, bar_y, bar_center_x + bar_w // 2, bar_y + bar_h),
fill=0, outline=255,
)
# Indicator position
pos = int((cents + 50) / 100 * bar_w) - bar_w // 2
indicator_x = bar_center_x + pos
draw.rectangle(
(indicator_x - 2, bar_y - 1, indicator_x + 2, bar_y + bar_h + 1),
fill=255,
)
# Cents text
draw.text(
(MARGIN, FOOTER_Y),
f"{cents:+d} cents",
fill=255,
font=font_normal,
)
def _render_fx_edit(self, draw) -> None:
"""Render FX parameter edit mode."""
s = self._state
font = self._fonts.get("normal")
draw.text((MARGIN, MARGIN), f"EDIT: {s.param_name}", fill=255, font=font)
# Parameter value bar
val = max(0.0, min(1.0, s.param_value))
bar_x = MARGIN
bar_y = 20
bar_w = DISPLAY_W - 2 * MARGIN
bar_h = 8
draw.rectangle((bar_x, bar_y, bar_x + bar_w, bar_y + bar_h), fill=0, outline=255)
fill_w = int(bar_w * val)
draw.rectangle((bar_x, bar_y, bar_x + fill_w, bar_y + bar_h), fill=255)
# Value text
draw.text((MARGIN, bar_y + 12), f"{val:.2f}", fill=255, font=font)
def _render_settings(self, draw) -> None:
"""Render settings mode."""
s = self._state
font = self._fonts.get("normal")
draw.text((MARGIN, MARGIN), "SETTINGS", fill=255, font=font)
draw.text((MARGIN, 20), s.param_name or "", fill=255, font=font)
def _splash(self) -> None:
"""Show boot splash screen."""
if not self._initialized or self._draw is None or self._image is None:
return
draw = self._draw
draw.rectangle((0, 0, DISPLAY_W - 1, DISPLAY_H - 1), fill=0)
draw.text((24, 24), "Pi Multi-FX", fill=255, font=self._fonts.get("large"))
draw.text((28, 44), "Booting...", fill=255, font=self._fonts.get("normal"))
self._display.image(self._image)
self._display.show()
def clear(self) -> None:
"""Clear the display."""
if self._initialized and self._display is not None:
self._display.fill(0)
self._display.show()
+251
View File
@@ -0,0 +1,251 @@
"""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
+321
View File
@@ -0,0 +1,321 @@
"""RGB LED controller for the Pi Multi-FX Pedal.
Controls WS2812B (NeoPixel) or APA102 (DotStar) LEDs for:
- Per-footswitch status LEDs
- Bypass indicator (red/green)
- Preset navigation animations
- Tap tempo flashing
- Configurable brightness
"""
from __future__ import annotations
import logging
import math
import time
from dataclasses import dataclass, field
from enum import Enum
from typing import Callable, Optional
logger = logging.getLogger(__name__)
class LEDDriver(Enum):
"""Which LED driver to use."""
NEOPIXEL = "neopixel" # WS2812B — adafruit-circuitpython-neopixel
DOTSTAR = "dotstar" # APA102 — adafruit-circuitpython-dotstar
MOCK = "mock" # No hardware — log only
class LEDPattern(Enum):
"""Built-in LED animation patterns."""
SOLID = "solid"
PULSE = "pulse" # Gentle breathe
BLINK = "blink" # On/off square wave
TAP_TEMPO = "tap_tempo" # Flash at detected BPM
SCAN = "scan" # Chase across strip
PRESET_UP = "preset_up" # Sweep up
PRESET_DOWN = "preset_down" # Sweep down
@dataclass
class LEDConfig:
"""Per-LED configuration."""
index: int
default_color: tuple[int, int, int] = (0, 0, 0) # RGB
default_brightness: float = 0.5
label: str = ""
@dataclass
class LEDAnimation:
"""Active animation state."""
pattern: LEDPattern
color: tuple[int, int, int]
speed_ms: int = 500 # Cycle time in ms
brightness: float = 0.5
start_time: float = 0.0
repeats: int = 0 # 0 = infinite
def _import_driver(driver: LEDDriver):
"""Import the correct LED driver library."""
if driver == LEDDriver.NEOPIXEL:
try:
import board
import neopixel
return neopixel
except (ImportError, NotImplementedError):
logger.warning("NeoPixel not available — falling back to mock")
return None
elif driver == LEDDriver.DOTSTAR:
try:
import board
import adafruit_dotstar as dotstar
return dotstar
except (ImportError, NotImplementedError):
logger.warning("DotStar not available — falling back to mock")
return None
return None
class LEDController:
"""RGB LED controller with animation support.
Handles per-LED animations for preset navigation, bypass status,
tap tempo, and tuner mode.
"""
def __init__(
self,
num_leds: int,
driver: LEDDriver = LEDDriver.NEOPIXEL,
pin: str = "D18",
brightness: float = 0.5,
led_configs: Optional[list[LEDConfig]] = None,
):
self._num_leds = num_leds
self._driver_type = driver
self._pin = pin
self._global_brightness = brightness
self._led_configs = led_configs or [
LEDConfig(i, default_brightness=brightness) for i in range(num_leds)
]
# Physical LED strip handle
self._strip = None
self._initialized = False
# Current pixel colors (RGB)
self._pixels: list[tuple[int, int, int]] = [(0, 0, 0)] * num_leds
# Active animations per LED
self._animations: dict[int, LEDAnimation] = {}
# Callbacks
self._animation_callbacks: list[Callable] = []
def initialize(self) -> bool:
"""Initialize the LED strip hardware.
Returns False in dev mode (no hardware).
"""
try:
driver_mod = _import_driver(self._driver_type)
if driver_mod is None or self._driver_type == LEDDriver.MOCK:
self._initialized = False
logger.info("LED driver in MOCK mode — no hardware")
return False
if self._driver_type == LEDDriver.NEOPIXEL:
import board
self._strip = driver_mod.NeoPixel(
getattr(board, self._pin),
self._num_leds,
brightness=self._global_brightness,
auto_write=False,
)
elif self._driver_type == LEDDriver.DOTSTAR:
import board
self._strip = driver_mod.DotStar(
board.SCK, board.MOSI,
self._num_leds,
brightness=self._global_brightness,
auto_write=False,
)
self._initialized = True
logger.info(
"LED strip initialized: %d LEDs, %s @ %s",
self._num_leds, self._driver_type.value, self._pin,
)
return True
except Exception as e:
logger.info("No LED strip detected — running in mock mode: %s", e)
self._initialized = False
return False
# --- Pixel control ---
def set_pixel(
self,
index: int,
color: tuple[int, int, int],
brightness: Optional[float] = None,
) -> None:
"""Set a single LED to a color (0-255 per channel)."""
if not 0 <= index < self._num_leds:
logger.warning("LED index %d out of range (0-%d)", index, self._num_leds - 1)
return
bri = brightness if brightness is not None else self._global_brightness
r, g, b = self._clamp_color(color)
# Apply brightness scaling
self._pixels[index] = (int(r * bri), int(g * bri), int(b * bri))
self._write_pixel(index)
def set_all(
self,
color: tuple[int, int, int],
brightness: Optional[float] = None,
) -> None:
"""Set all LEDs to the same color."""
bri = brightness if brightness is not None else self._global_brightness
r, g, b = self._clamp_color(color)
scaled = (int(r * bri), int(g * bri), int(b * bri))
self._pixels = [scaled] * self._num_leds
self._write_all()
def set_bypass_led(self, index: int, bypassed: bool) -> None:
"""Set a bypass indicator LED.
Red = bypassed, Green = active.
"""
if bypassed:
self.set_pixel(index, (255, 0, 0))
else:
self.set_pixel(index, (0, 255, 0))
def preset_animate(self, direction: str = "up") -> None:
"""Animate preset change (sweep).
direction: "up" → sweep left-to-right, "down" → right-to-left.
"""
self._animate_scan(
color=(0, 64, 255), # Blue
reverse=(direction == "down"),
speed_ms=120,
)
def tap_tempo_blip(self) -> None:
"""Quick white flash indicating a tap tempo hit."""
old = self._pixels[:]
# Flash all LEDs white briefly
self.set_all((255, 255, 255), brightness=0.8)
time.sleep(0.03)
# Restore — but don't block, schedule async
for i in range(self._num_leds):
self._pixels[i] = old[i]
self._write_all()
def tap_tempo_animate(self, bpm: float) -> None:
"""Start a tempo-synchronized flash on all LEDs.
Animates in sync with the detected BPM.
The caller still drives the actual flash via tap_tempo_blip;
this sets a subtle pulse background at the tempo.
"""
period_ms = int(60000 / max(bpm, 20)) # ms per beat
self._animate_all(
pattern=LEDPattern.TAP_TEMPO,
color=(255, 255, 255),
speed_ms=period_ms,
brightness=0.15, # Subtle
)
def clear_all(self) -> None:
"""Turn off all LEDs."""
self._pixels = [(0, 0, 0)] * self._num_leds
self._animations.clear()
self._write_all()
def set_brightness(self, brightness: float) -> None:
"""Set global brightness (0.0 - 1.0)."""
self._global_brightness = max(0.0, min(1.0, brightness))
if self._strip is not None:
self._strip.brightness = self._global_brightness
logger.info("Global brightness set to %.2f", self._global_brightness)
# --- Animation engine ---
def _animate_all(
self,
pattern: LEDPattern,
color: tuple[int, int, int],
speed_ms: int,
brightness: float,
) -> None:
"""Start an animation on all LEDs."""
return None # Full animation tick runs on _animation_tick
def _animate_scan(
self,
color: tuple[int, int, int],
reverse: bool = False,
speed_ms: int = 120,
) -> None:
"""Run a scan animation in a separate thread (non-blocking)."""
import threading
def _scan():
r, g, b = color
indices = range(self._num_leds)
if reverse:
indices = reversed(indices)
for i in indices:
self.set_all((0, 0, 0))
self.set_pixel(i, (r, g, b))
self._write_all()
time.sleep(speed_ms / 1000)
self.set_all((0, 0, 0))
self._write_all()
t = threading.Thread(target=_scan, daemon=True, name="led-scan")
t.start()
# --- Internal helpers ---
def _clamp_color(self, color: tuple[int, int, int]) -> tuple[int, int, int]:
"""Clamp 0-255 per channel."""
return (
max(0, min(255, color[0])),
max(0, min(255, color[1])),
max(0, min(255, color[2])),
)
def _write_pixel(self, index: int) -> None:
"""Write a single pixel to the hardware strip."""
if self._strip is not None and self._initialized:
self._strip[index] = self._pixels[index]
self._strip.show()
else:
logger.debug("LED[%d] → RGB(%d,%d,%d)", index, *self._pixels[index])
def _write_all(self) -> None:
"""Write all pixels to the hardware strip."""
if self._strip is not None and self._initialized:
for i in range(self._num_leds):
self._strip[i] = self._pixels[i]
self._strip.show()
else:
for i, c in enumerate(self._pixels):
if any(v > 0 for v in c):
logger.debug("LED[%d] → RGB(%d,%d,%d)", i, *c)
def __enter__(self):
self.initialize()
return self
def __exit__(self, exc_type, exc_val, exc_tb) -> None:
self.clear_all()