"""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()