diff --git a/GUIDE.md b/GUIDE.md
index 4ea52a3b..81a7335f 100644
--- a/GUIDE.md
+++ b/GUIDE.md
@@ -61,8 +61,8 @@ curl -s http://localhost:80/pedalboard/list | python3 -m json.tool
## Hardware Versions
-- **v1/v2**: Uses `modalapi/mod.py`
-- **v3**: Uses `modalapi/modhandler.py` (current device)
+- **v1**: Uses `modalapi/mod.py` (legacy handler)
+- **v2/v3**: Uses `modalapi/modhandler.py` (current device)
## Python Environment
@@ -228,6 +228,11 @@ Shortpress accepts string (callback name) or object with `callback` and `args` (
### Hardware Version Selection
+**Version float** comes from `hardware.version` in the active YAML config, selected from templates in `setup/config_templates/`:
+- `default_config_pistomp.yml` → `1.0`
+- `default_config_pistompcore.yml` → `2.0`
+- `default_config_pistomptre.yml` → `3.0`
+
**Factory Pattern** routes version-specific implementations:
```python
@@ -247,6 +252,8 @@ Shortpress accepts string (callback name) or object with `callback` and `args` (
- SPI/ADC communication
- Controller dictionary: `{channel:CC}` → controller object
+**LCD wiring**: each hardware subclass creates the LCD in `init_lcd()` and injects it into the handler via `handler.add_lcd(Lcd(...))`. The LCD is owned by the handler (`handler._lcd`), not the hardware. For v2/v3, `lcd320x240.Lcd` receives a back-reference to the handler for UI action callbacks (pedalboard/preset change, plugin bypass, parameter edits, system menu, etc.).
+
### Configuration System
**Two-Layer Config Overlay**:
diff --git a/emulator/__init__.py b/emulator/__init__.py
new file mode 100644
index 00000000..472fb2ea
--- /dev/null
+++ b/emulator/__init__.py
@@ -0,0 +1,17 @@
+import os
+from pathlib import Path
+from PIL import ImageFont
+
+_FONTS_DIR = Path(__file__).parent.parent / "fonts"
+_orig_truetype = ImageFont.truetype
+
+
+def _resolve_truetype(font=None, size=10, **kwargs):
+ if isinstance(font, str) and not Path(font).is_absolute() and not Path(font).exists():
+ candidate = _FONTS_DIR / font
+ if candidate.exists():
+ font = str(candidate)
+ return _orig_truetype(font, size, **kwargs)
+
+
+ImageFont.truetype = _resolve_truetype
diff --git a/emulator/bootstrap.py b/emulator/bootstrap.py
new file mode 100644
index 00000000..fc4cab4f
--- /dev/null
+++ b/emulator/bootstrap.py
@@ -0,0 +1,86 @@
+# This file is part of pi-stomp.
+#
+# pi-stomp is free software: you can redistribute it and/or modify
+# it under the terms of the GNU General Public License as published by
+# the Free Software Foundation, either version 3 of the License, or
+# (at your option) any later version.
+#
+# pi-stomp is distributed in the hope that it will be useful,
+# but WITHOUT ANY WARRANTY; without even the implied warranty of
+# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+# GNU General Public License for more details.
+#
+# You should have received a copy of the GNU General Public License
+# along with pi-stomp. If not, see .
+
+import logging
+import os
+from typing import Literal
+
+from rtmidi.midiutil import open_midioutput
+
+import pistomp.config as config
+import pistomp.settings as Settings_module
+
+EmulatorVersion = Literal["emulator_v1", "emulator_v2", "emulator_v3"]
+
+_REPO_ROOT = os.path.dirname(os.path.dirname(os.path.realpath(__file__)))
+_CONFIG_TEMPLATES = {
+ "emulator_v1": os.path.join(_REPO_ROOT, "setup", "config_templates", "default_config_pistomp.yml"),
+ "emulator_v2": os.path.join(_REPO_ROOT, "setup", "config_templates", "default_config_pistompcore.yml"),
+ "emulator_v3": os.path.join(_REPO_ROOT, "setup", "config_templates", "default_config_pistomptre.yml"),
+}
+
+
+def bootstrap_emulator(version: EmulatorVersion, cwd: str):
+ """Initialize pygame, build the emulator handler/hardware, and return (handler, midiout)."""
+ import pygame
+ import pygame._freetype as _freetype
+ from emulator.window import EmulatorWindow
+
+ match version:
+ case "emulator_v1":
+ from emulator.hardware_v1 import EmulatorHardwareV1 as EmuHW
+ from emulator.mod import EmulatorMod as EmuHandler
+ case "emulator_v2":
+ from emulator.hardware_v2 import EmulatorHardwareV2 as EmuHW
+ from emulator.modhandler import EmulatorModhandler as EmuHandler
+ case "emulator_v3":
+ from emulator.hardware_v3 import EmulatorHardwareV3 as EmuHW
+ from emulator.modhandler import EmulatorModhandler as EmuHandler
+
+ pygame.init()
+ _freetype.init()
+
+ try:
+ midiout, _port_name = open_midioutput(0)
+ except Exception:
+ logging.warning("MIDI output unavailable in emulator mode - continuing without MIDI")
+ midiout = None
+
+ cfg = config.load_cfg_from_file(_CONFIG_TEMPLATES[version])
+
+ if version != "emulator_v1":
+ emu_cfg_dir = os.path.join(os.path.expanduser("~"), ".pistomp_emulator", "config")
+ os.makedirs(emu_cfg_dir, exist_ok=True)
+ Settings_module.DATA_DIR = emu_cfg_dir
+
+ handler = EmuHandler(cwd)
+ hw = EmuHW(cfg, handler, midiout, refresh_callback=handler.update_lcd_fs)
+ handler.add_hardware(hw)
+
+ window = EmulatorWindow(hw)
+ handler.set_window(window)
+
+ handler.load_banks()
+ handler.load_pedalboards()
+
+ current_bundle = handler.get_current_pedalboard_bundle_path()
+ if current_bundle and current_bundle in handler.pedalboards:
+ handler.set_current_pedalboard(handler.pedalboards[current_bundle])
+ else:
+ handler.pedalboard_change()
+
+ handler.system_info_load()
+
+ return handler, midiout
diff --git a/emulator/controls.py b/emulator/controls.py
new file mode 100644
index 00000000..7755105c
--- /dev/null
+++ b/emulator/controls.py
@@ -0,0 +1,144 @@
+# This file is part of pi-stomp.
+#
+# pi-stomp is free software: you can redistribute it and/or modify
+# it under the terms of the GNU General Public License as published by
+# the Free Software Foundation, either version 3 of the License, or
+# (at your option) any later version.
+#
+# pi-stomp is distributed in the hope that it will be useful,
+# but WITHOUT ANY WARRANTY; without even the implied warranty of
+# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+# GNU General Public License for more details.
+#
+# You should have received a copy of the GNU General Public License
+# along with pi-stomp. If not, see .
+
+"""Software-only stand-ins for the physical controls on pi-Stomp.
+
+Each mock control subclasses the corresponding real hardware class so that
+type checkers are satisfied, but bypasses any GPIO / SPI / ADC init.
+"""
+
+import pistomp.analogcontrol as analogcontrol
+import pistomp.encoder as encoder
+import pistomp.encodermidicontrol as encodermidicontrol
+import pistomp.footswitch as footswitch
+
+try:
+ from rtmidi.midiconstants import CONTROL_CHANGE
+ _rtmidi_available = True
+except ImportError:
+ _rtmidi_available = False
+ CONTROL_CHANGE = 0xB0
+
+
+class MockEncoder(encoder.Encoder):
+ """Nav encoder (no GPIO). Driven externally via step() / press()."""
+
+ def __init__(self, callback, type=None, id=None):
+ super().__init__(d_pin=None, clk_pin=None, callback=callback, type=type, id=id)
+ self.press_callback = None
+ self.label: str | None = None
+
+ def read_rotary(self):
+ pass
+
+ def step(self, direction):
+ if direction != 0 and self.callback:
+ self.callback(direction)
+
+ def press(self, value):
+ if self.press_callback:
+ self.press_callback(value)
+
+
+class MockEncoderMidi(encodermidicontrol.EncoderMidiControl):
+ """Tweak encoder with MIDI CC. Driven externally via step() / press()."""
+
+ def __init__(self, handler, callback, midi_channel, midi_CC, midiout,
+ type=None, id=None, cfg=None):
+ super().__init__(handler=handler, d_pin=None, clk_pin=None, callback=callback,
+ midi_CC=midi_CC, midi_channel=midi_channel, midiout=midiout,
+ type=type, id=id)
+ self.cfg = cfg or {'type': type, 'id': id}
+ self.midi_value = 64
+ self.press_callback = None
+
+ def read_rotary(self):
+ pass
+
+ def set_value(self, value):
+ self.midi_value = int(value)
+
+ def step(self, direction):
+ self.midi_value = max(0, min(127, self.midi_value + direction))
+ if self.midiout and self.midi_CC is not None and _rtmidi_available:
+ self.midiout.send_message(
+ [CONTROL_CHANGE | (self.midi_channel & 0x0F),
+ self.midi_CC, self.midi_value])
+ if self.callback:
+ self.callback(direction)
+
+ def press(self, value):
+ if self.press_callback:
+ self.press_callback(value)
+
+
+class MockFootswitch(footswitch.Footswitch):
+ """Footswitch with no GPIO/ADC. Driven externally via press()."""
+
+ @classmethod
+ def check_longpress_events(cls):
+ pass
+
+ def __init__(self, id, midi_CC, midi_channel, midiout, refresh_callback):
+ # led_pin=None, pixel=None, gpio_input=None, adc_input=None — no GPIO paths taken
+ super().__init__(id, None, None, midi_CC, midi_channel, midiout, refresh_callback)
+ self.type = None
+ self.cfg = {}
+
+ def poll(self):
+ pass
+
+ def press(self):
+ self.enabled = not self.enabled
+ if self.midiout and self.midi_CC is not None and _rtmidi_available:
+ self.midiout.send_message(
+ [CONTROL_CHANGE | (self.midi_channel & 0x0F),
+ self.midi_CC, 127 if self.enabled else 0])
+ self.refresh_callback(footswitch=self)
+
+
+class MockAnalogControl(analogcontrol.AnalogControl):
+ """Expression pedal / knob with no SPI/ADC. Value set externally."""
+
+ def __init__(self, midi_CC, midi_channel, midiout, control_type=None,
+ id=None, cfg=None):
+ # AnalogControl.__init__ only stores spi/channel/tolerance — safe with None
+ super().__init__(spi=None, adc_channel=None, tolerance=0)
+ self.midi_CC = midi_CC
+ self.midi_channel = midi_channel
+ self.midiout = midiout
+ self.type = control_type
+ self.id = id
+ self.cfg = cfg or {'type': control_type, 'id': id}
+ self.value = 64
+ self.parameter = None
+
+ def refresh(self):
+ pass
+
+ def initialize(self):
+ pass
+
+ def set_midi_channel(self, ch):
+ self.midi_channel = ch
+
+ def set_value(self, value):
+ self.value = int(value)
+
+ def send_midi(self, value_0_127):
+ if self.midiout and self.midi_CC is not None and _rtmidi_available:
+ self.midiout.send_message(
+ [CONTROL_CHANGE | (self.midi_channel & 0x0F),
+ self.midi_CC, int(value_0_127)])
diff --git a/emulator/gfxhat_adapters.py b/emulator/gfxhat_adapters.py
new file mode 100644
index 00000000..84594c38
--- /dev/null
+++ b/emulator/gfxhat_adapters.py
@@ -0,0 +1,62 @@
+# This file is part of pi-stomp.
+#
+# pi-stomp is free software: you can redistribute it and/or modify
+# it under the terms of the GNU General Public License as published by
+# the Free Software Foundation, either version 3 of the License, or
+# (at your option) any later version.
+#
+# pi-stomp is distributed in the hope that it will be useful,
+# but WITHOUT ANY WARRANTY; without even the implied warranty of
+# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+# GNU General Public License for more details.
+#
+# You should have received a copy of the GNU General Public License
+# along with pi-stomp. If not, see .
+
+"""Software stubs for the three gfxhat modules (lcd, backlight, touch).
+
+GfxLcd renders to a LcdPygame backend via set_pixel/show.
+GfxBacklight and GfxTouch are no-ops.
+"""
+
+from PIL import Image
+
+
+class GfxLcd:
+ def __init__(self, lcd_pygame, width=128, height=64):
+ self._pygame = lcd_pygame
+ self._w = width
+ self._h = height
+ self._buf = Image.new('L', (width, height))
+
+ def dimensions(self):
+ return (self._w, self._h)
+
+ def set_pixel(self, x, y, val):
+ if 0 <= x < self._w and 0 <= y < self._h:
+ self._buf.putpixel((x, y), 255 if val else 0)
+
+ def show(self):
+ # Production code stores pixels with both axes inverted (hardware orientation).
+ # Rotate 180° to recover the correct image for pygame display.
+ img = self._buf.transpose(Image.ROTATE_180)
+ self._pygame.update(img.convert('RGB'))
+
+ def clear(self):
+ self._buf.paste(0, (0, 0, self._w, self._h))
+
+
+class GfxBacklight:
+ def set_pixel(self, x, r, g, b):
+ pass
+
+ def set_all(self, r, g, b):
+ pass
+
+ def show(self):
+ pass
+
+
+class GfxTouch:
+ def set_led(self, i, val):
+ pass
diff --git a/emulator/hardware_base.py b/emulator/hardware_base.py
new file mode 100644
index 00000000..31cd9f1c
--- /dev/null
+++ b/emulator/hardware_base.py
@@ -0,0 +1,108 @@
+# This file is part of pi-stomp.
+#
+# pi-stomp is free software: you can redistribute it and/or modify
+# it under the terms of the GNU General Public License as published by
+# the Free Software Foundation, either version 3 of the License, or
+# (at your option) any later version.
+#
+# pi-stomp is distributed in the hope that it will be useful,
+# but WITHOUT ANY WARRANTY; without even the implied warranty of
+# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+# GNU General Public License for more details.
+#
+# You should have received a copy of the GNU General Public License
+# along with pi-stomp. If not, see .
+
+"""Shared base for all emulator Hardware subclasses.
+
+Provides init_lcd, init_footswitches, init_analog_controls, init_relays,
+cleanup, and test. Subclasses only need to implement init_encoders (and
+optionally add_encoder for config-driven encoder creation).
+"""
+
+import pistomp.hardware as hardware
+import common.token as Token
+import common.util as Util
+
+from emulator.controls import MockFootswitch, MockAnalogControl, MockEncoder
+from emulator.lcd_pygame import LcdPygame
+from emulator.stubs import StubRelay
+
+
+class EmulatorHardwareBase(hardware.Hardware):
+
+ VERSION_LABEL = ""
+ lcd_flip = False
+
+ def __init__(self, cfg, handler, midiout, refresh_callback):
+ super().__init__(cfg, handler, midiout, refresh_callback)
+ # spi stays None — no init_spi() call
+
+ self.lcd_pygame: LcdPygame | None = None
+ self.nav_encoder: MockEncoder | None = None
+ self.tweak_encoders: list = []
+ self.volume_encoder: MockEncoder | None = None
+
+ # Ensure relay is always a stub so bypass footswitch config doesn't crash
+ self.init_relays()
+
+ # -------------------------------------------------------------------------
+ # Shared init helpers
+ # -------------------------------------------------------------------------
+
+ def init_lcd(self):
+ import pistomp.lcd320x240 as Lcd
+ self.lcd_pygame = LcdPygame(320, 240)
+ self.handler.add_lcd(Lcd.Lcd(self.handler.homedir, self.handler,
+ flip=self.lcd_flip, display=self.lcd_pygame))
+
+ def init_footswitches(self):
+ cfg = self.default_cfg.copy()
+ cfg_fs = cfg.get(Token.HARDWARE, {}).get(Token.FOOTSWITCHES)
+ if not cfg_fs:
+ return
+
+ midi_channel = self.get_real_midi_channel(cfg)
+ for f in cfg_fs:
+ if Util.DICT_GET(f, Token.DISABLE):
+ continue
+ id_ = Util.DICT_GET(f, Token.ID)
+ midi_cc = Util.DICT_GET(f, Token.MIDI_CC)
+ fs = MockFootswitch(id_, midi_cc, midi_channel,
+ self.midiout, self.refresh_callback)
+ self.footswitches.append(fs)
+ if midi_cc is not None:
+ key = "%d:%d" % (midi_channel, midi_cc)
+ self.controllers[key] = fs
+
+ def init_analog_controls(self):
+ cfg = self.default_cfg.copy()
+ hw_cfg = cfg.get(Token.HARDWARE, {}) if cfg else {}
+ cfg_c = hw_cfg.get(Token.ANALOG_CONTROLLERS)
+ if not cfg_c:
+ return
+
+ midi_channel = self.get_real_midi_channel(cfg)
+ for c in cfg_c:
+ if Util.DICT_GET(c, Token.DISABLE):
+ continue
+ id_ = Util.DICT_GET(c, Token.ID)
+ midi_cc = Util.DICT_GET(c, Token.MIDI_CC)
+ control_type = Util.DICT_GET(c, Token.TYPE)
+ if midi_cc is None:
+ continue
+ ctrl = MockAnalogControl(midi_cc, midi_channel, self.midiout,
+ control_type, id_, c)
+ self.analog_controls.append(ctrl)
+ key = "%d:%d" % (midi_channel, midi_cc)
+ self.controllers[key] = ctrl
+
+ def init_relays(self):
+ self.relay = StubRelay()
+
+ def cleanup(self):
+ import pygame
+ pygame.quit()
+
+ def test(self):
+ pass
diff --git a/emulator/hardware_v1.py b/emulator/hardware_v1.py
new file mode 100644
index 00000000..c06c25e7
--- /dev/null
+++ b/emulator/hardware_v1.py
@@ -0,0 +1,60 @@
+# This file is part of pi-stomp.
+#
+# pi-stomp is free software: you can redistribute it and/or modify
+# it under the terms of the GNU General Public License as published by
+# the Free Software Foundation, either version 3 of the License, or
+# (at your option) any later version.
+#
+# pi-stomp is distributed in the hope that it will be useful,
+# but WITHOUT ANY WARRANTY; without even the implied warranty of
+# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+# GNU General Public License for more details.
+#
+# You should have received a copy of the GNU General Public License
+# along with pi-stomp. If not, see .
+
+"""EmulatorHardwareV1 — software-only Hardware subclass for the v1 emulator.
+
+Matches the original pi-Stomp (Pistomp): two encoders with press (top for
+pedalboard/preset nav, bottom for effect nav), three footswitches, no
+config-driven analog controls.
+"""
+
+from emulator.hardware_base import EmulatorHardwareBase
+from emulator.controls import MockEncoder
+from emulator.lcd_pygame import LcdPygame
+from emulator.lcd_lcdgfx import Lcd as LcdGfx
+
+
+class EmulatorHardwareV1(EmulatorHardwareBase):
+
+ VERSION_LABEL = "v1"
+ lcd_flip = False
+
+ def __init__(self, cfg, handler, midiout, refresh_callback):
+ super().__init__(cfg, handler, midiout, refresh_callback)
+
+ self.init_lcd()
+ self.init_encoders()
+ self.init_footswitches()
+ self.init_analog_controls()
+
+ def init_lcd(self):
+ self.lcd_pygame = LcdPygame(128, 64)
+ self.handler.add_lcd(LcdGfx(self.handler.homedir, self.lcd_pygame))
+
+ def init_encoders(self):
+ top = MockEncoder(callback=self.handler.top_encoder_select, id=0)
+ top.press_callback = self.handler.top_encoder_sw
+ top.label = "Nav1 (pedalboard/preset)"
+ self.encoders.append(top)
+ self.nav_encoder = top
+
+ bot = MockEncoder(callback=self.handler.bot_encoder_select, id=1)
+ bot.press_callback = self.handler.bottom_encoder_sw
+ bot.label = "Nav2 (plugin/value)"
+ self.encoders.append(bot)
+ self.tweak_encoders.append(bot)
+
+ def add_encoder(self, id, type, callback, longpress_callback, midi_channel, midi_cc):
+ pass # v1 has no config-driven encoders
diff --git a/emulator/hardware_v2.py b/emulator/hardware_v2.py
new file mode 100644
index 00000000..1d82c7d6
--- /dev/null
+++ b/emulator/hardware_v2.py
@@ -0,0 +1,47 @@
+# This file is part of pi-stomp.
+#
+# pi-stomp is free software: you can redistribute it and/or modify
+# it under the terms of the GNU General Public License as published by
+# the Free Software Foundation, either version 3 of the License, or
+# (at your option) any later version.
+#
+# pi-stomp is distributed in the hope that it will be useful,
+# but WITHOUT ANY WARRANTY; without even the implied warranty of
+# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+# GNU General Public License for more details.
+#
+# You should have received a copy of the GNU General Public License
+# along with pi-stomp. If not, see .
+
+"""EmulatorHardwareV2 — software-only Hardware subclass for the v2 emulator.
+
+Matches pi-Stomp Core (Pistompcore): one nav encoder with press, three
+footswitches, one pot, one expression pedal, no relay interaction.
+"""
+
+from emulator.hardware_base import EmulatorHardwareBase
+from emulator.controls import MockEncoder
+
+
+class EmulatorHardwareV2(EmulatorHardwareBase):
+
+ VERSION_LABEL = "v2"
+ lcd_flip = True
+
+ def __init__(self, cfg, handler, midiout, refresh_callback):
+ super().__init__(cfg, handler, midiout, refresh_callback)
+
+ self.init_lcd()
+ self.init_encoders()
+ self.init_footswitches()
+ self.init_analog_controls()
+
+ def init_encoders(self):
+ nav = MockEncoder(callback=self.handler.universal_encoder_select, id=0)
+ nav.press_callback = self.handler.universal_encoder_sw
+ self.encoders.append(nav)
+ self.nav_encoder = nav
+ # tweak_encoders and volume_encoder stay None/[] — v2 has no extras
+
+ def add_encoder(self, id, type, callback, longpress_callback, midi_channel, midi_cc):
+ pass # v2 has no config-driven encoders
diff --git a/emulator/hardware_v3.py b/emulator/hardware_v3.py
new file mode 100644
index 00000000..7b481fba
--- /dev/null
+++ b/emulator/hardware_v3.py
@@ -0,0 +1,74 @@
+# This file is part of pi-stomp.
+#
+# pi-stomp is free software: you can redistribute it and/or modify
+# it under the terms of the GNU General Public License as published by
+# the Free Software Foundation, either version 3 of the License, or
+# (at your option) any later version.
+#
+# pi-stomp is distributed in the hope that it will be useful,
+# but WITHOUT ANY WARRANTY; without even the implied warranty of
+# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+# GNU General Public License for more details.
+#
+# You should have received a copy of the GNU General Public License
+# along with pi-stomp. If not, see .
+
+"""EmulatorHardwareV3 — software-only Hardware subclass for the v3 emulator.
+
+Matches pi-Stomp Tre (Pistomptre): one nav encoder, two tweak encoders,
+one volume encoder, four footswitches, expression pedal.
+"""
+
+import common.token as Token
+
+from emulator.hardware_base import EmulatorHardwareBase
+from emulator.controls import MockEncoder, MockEncoderMidi
+
+
+class EmulatorHardwareV3(EmulatorHardwareBase):
+
+ VERSION_LABEL = "v3"
+ lcd_flip = False
+
+ def __init__(self, cfg, handler, midiout, refresh_callback):
+ super().__init__(cfg, handler, midiout, refresh_callback)
+
+ self.init_lcd()
+ self.init_encoders()
+ self.init_footswitches()
+ self.init_analog_controls()
+
+ def init_encoders(self):
+ nav = MockEncoder(callback=self.handler.universal_encoder_select, id=0)
+ nav.press_callback = self.handler.universal_encoder_sw
+ self.encoders.append(nav)
+ self.nav_encoder = nav
+
+ cfg = self.default_cfg.copy()
+ self.create_encoders(cfg)
+
+ def add_encoder(self, id, type, callback, longpress_callback, midi_channel, midi_cc):
+ """Called by Hardware.create_encoders() for each encoder in config."""
+ if type == Token.VOLUME:
+ enc = MockEncoder(
+ callback=self.handler.system_menu_headphone_volume,
+ type=type, id=id)
+ self.volume_encoder = enc
+ else:
+ enc = MockEncoderMidi(
+ handler=self.handler,
+ callback=callback,
+ midi_channel=midi_channel,
+ midi_CC=midi_cc,
+ midiout=self.midiout,
+ type=Token.KNOB,
+ id=id)
+ enc.press_callback = self.handler.universal_encoder_sw
+ self.tweak_encoders.append(enc)
+
+ if longpress_callback:
+ lp = self.handler.get_callback(longpress_callback)
+ if lp and isinstance(enc, MockEncoderMidi):
+ enc.press_callback = lp
+
+ return enc
diff --git a/emulator/lcd_lcdgfx.py b/emulator/lcd_lcdgfx.py
new file mode 100644
index 00000000..a125a080
--- /dev/null
+++ b/emulator/lcd_lcdgfx.py
@@ -0,0 +1,68 @@
+# This file is part of pi-stomp.
+#
+# pi-stomp is free software: you can redistribute it and/or modify
+# it under the terms of the GNU General Public License as published by
+# the Free Software Foundation, either version 3 of the License, or
+# (at your option) any later version.
+#
+# pi-stomp is distributed in the hope that it will be useful,
+# but WITHOUT ANY WARRANTY; without even the implied warranty of
+# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+# GNU General Public License for more details.
+#
+# You should have received a copy of the GNU General Public License
+# along with pi-stomp. If not, see .
+
+"""Emulator LCD subclass for the v1 pi-Stomp (GFX HAT 128x64 display).
+
+Extends pistomp.lcdgfx.Lcd with the emulator-specific methods that
+modhandler calls (link_data, draw_main_panel, update_footswitch, etc.).
+All rendering logic lives in the parent class via injected GfxHat adapters.
+"""
+
+import pistomp.lcdgfx as lcdgfx
+from emulator.gfxhat_adapters import GfxLcd, GfxBacklight, GfxTouch
+
+
+class Lcd(lcdgfx.Lcd):
+
+ def __init__(self, cwd, lcd_pygame):
+ super().__init__(
+ cwd,
+ lcd=GfxLcd(lcd_pygame),
+ backlight=GfxBacklight(),
+ touch=GfxTouch(),
+ )
+ self._current = None
+ self._footswitches = []
+
+ def enc_step(self, direction):
+ pass
+
+ def enc_sw(self, value):
+ pass
+
+ def link_data(self, pedalboards, current, footswitches):
+ self._current = current
+ self._footswitches = footswitches
+
+ def draw_main_panel(self):
+ if self._current is None:
+ return
+ pb = self._current.pedalboard
+ presets = self._current.presets
+ preset_name = presets.get(self._current.preset_index) if presets else None
+ self.draw_title(pb.title if hasattr(pb, 'title') else str(pb), preset_name, False, False)
+ self.draw_analog_assignments(self._current.analog_controllers)
+ self.draw_plugins(pb.plugins)
+ self.draw_bound_plugins(pb.plugins, self._footswitches)
+
+ def update_footswitch(self, footswitch):
+ if self._current is not None:
+ self.erase_zone(7)
+ self.draw_bound_plugins(self._current.pedalboard.plugins, self._footswitches)
+
+ def update_footswitches(self):
+ if self._current is not None:
+ self.erase_zone(7)
+ self.draw_bound_plugins(self._current.pedalboard.plugins, self._footswitches)
diff --git a/emulator/lcd_pygame.py b/emulator/lcd_pygame.py
new file mode 100644
index 00000000..3cb89f3f
--- /dev/null
+++ b/emulator/lcd_pygame.py
@@ -0,0 +1,93 @@
+# This file is part of pi-stomp.
+#
+# pi-stomp is free software: you can redistribute it and/or modify
+# it under the terms of the GNU General Public License as published by
+# the Free Software Foundation, either version 3 of the License, or
+# (at your option) any later version.
+#
+# pi-stomp is distributed in the hope that it will be useful,
+# but WITHOUT ANY WARRANTY; without even the implied warranty of
+# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+# GNU General Public License for more details.
+#
+# You should have received a copy of the GNU General Public License
+# along with pi-stomp. If not, see .
+
+import queue
+import threading
+import time
+
+import pygame
+from uilib.panel import LcdBase
+
+
+class LcdPygame(LcdBase):
+ """LCD implementation that renders into a pygame Surface.
+
+ SPI transfers run on a background worker thread so the main thread
+ (pygame event polling + encoder handling) stays responsive during long
+ transfers, which block for a simulated amount of time on the SPI thread.
+ """
+
+ # Bits per pixel when clocked over SPI. PIL 'RGB' is 24bpp in memory but
+ # ILI9341 takes RGB565 on the wire; '1'/'L' are mono/8bpp for small OLEDs.
+ _BPP_BY_MODE = {"1": 1, "L": 8, "RGB": 16, "RGBA": 16}
+
+ # time.sleep() on macOS has ~1 ms granularity, so sleeping for values
+ # smaller than this just burns wall-clock time waking up. Below the
+ # threshold we skip the sleep — pygame's own blit overhead already
+ # eats a few hundred µs per call which roughly approximates SPI cost
+ # at this scale.
+ _SLEEP_THRESHOLD_S = 0.001
+
+ def __init__(self, width=320, height=240, spi_hz=24_000_000):
+ self.width = width
+ self.height = height
+ self.spi_hz = spi_hz
+ self.surface = pygame.Surface((width, height))
+ self._lock = threading.Lock()
+ self._queue = queue.SimpleQueue()
+ self._worker = threading.Thread(target=self._spi_worker, daemon=True)
+ self._worker.start()
+
+ def _spi_worker(self):
+ while True:
+ pg_surf, dest, transfer_s, t0 = self._queue.get()
+ with self._lock:
+ self.surface.blit(pg_surf, dest)
+ deficit = transfer_s - (time.perf_counter() - t0)
+ if deficit >= self._SLEEP_THRESHOLD_S:
+ time.sleep(deficit)
+
+ def dimensions(self):
+ return (self.width, self.height)
+
+ def default_format(self):
+ return "RGB"
+
+ def update(self, image, box=None):
+ img_w, img_h = image.size
+
+ if box is not None:
+ x0, y0, x1, y1 = box.rect
+ x1 = min(x1, self.width)
+ y1 = min(y1, self.height)
+ # Crop if the image is larger than the dirty region
+ if x0 != 0 or y0 != 0 or x1 != img_w or y1 != img_h:
+ image = image.crop((x0, y0, x1, y1))
+ dest = (x0, y0)
+ else:
+ dest = (0, 0)
+
+ # Convert PIL→pygame on the main thread so the PIL image can be
+ # freed immediately; the worker only receives the finished Surface.
+ pg_surf = pygame.image.fromstring(image.tobytes(), image.size, image.mode)
+ bpp = self._BPP_BY_MODE.get(image.mode, 16)
+ transfer_s = (image.size[0] * image.size[1] * bpp) / self.spi_hz
+ self._queue.put((pg_surf, dest, transfer_s, time.perf_counter()))
+
+ def blit_scaled(self, dest_surface, dest_rect):
+ """Scale the LCD surface to dest_rect and blit it onto dest_surface."""
+ with self._lock:
+ scaled = pygame.transform.scale(self.surface, (dest_rect.width, dest_rect.height))
+ dest_surface.blit(scaled, dest_rect.topleft)
diff --git a/emulator/mod.py b/emulator/mod.py
new file mode 100644
index 00000000..8470e399
--- /dev/null
+++ b/emulator/mod.py
@@ -0,0 +1,105 @@
+# This file is part of pi-stomp.
+#
+# pi-stomp is free software: you can redistribute it and/or modify
+# it under the terms of the GNU General Public License as published by
+# the Free Software Foundation, either version 3 of the License, or
+# (at your option) any later version.
+#
+# pi-stomp is distributed in the hope that it will be useful,
+# but WITHOUT ANY WARRANTY; without even the implied warranty of
+# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+# GNU General Public License for more details.
+#
+# You should have received a copy of the GNU General Public License
+# along with pi-stomp. If not, see .
+
+"""EmulatorMod — Mod subclass for the v1 emulator.
+
+Inherits the full v1 dual-encoder state machine from modalapi.Mod.
+Overrides Pi-only I/O (wifi, system info, shutdown/reboot) and wires
+pygame rendering into the poll loop.
+
+Requires MOD Desktop running locally at http://127.0.0.1:18181.
+"""
+
+import logging
+import os
+
+from modalapi.mod import Mod
+from emulator.stubs import VirtualAudiocard, StubWifiManager
+
+
+class EmulatorMod(Mod):
+
+ def __init__(self, homedir):
+ import modalapi.wifi as _wifi_module
+ _orig_wm = _wifi_module.WifiManager
+ _wifi_module.WifiManager = lambda *a, **kw: StubWifiManager()
+ try:
+ super().__init__(VirtualAudiocard(), homedir)
+ finally:
+ _wifi_module.WifiManager = _orig_wm
+
+ emu_data_dir = os.path.join(os.path.expanduser("~"), ".pistomp_emulator")
+ self.pedalboard_modification_file = os.path.join(emu_data_dir, "last.json")
+ self.pedalboard_change_timestamp = 0
+
+ self.root_uri = "http://127.0.0.1:18181/"
+ self._window = None
+
+ def set_window(self, window):
+ self._window = window
+
+ # -------------------------------------------------------------------------
+ # Skip Pi-only system calls
+ # -------------------------------------------------------------------------
+
+ def poll_wifi(self):
+ pass
+
+ def poll_system_info(self):
+ pass
+
+ def system_info_load(self):
+ self.eq_status = self.audiocard.get_switch_parameter(self.audiocard.DAC_EQ)
+ self.lcd.update_eq(self.eq_status)
+
+ # -------------------------------------------------------------------------
+ # System menu: shutdown exits the emulator; everything else is a no-op
+ # -------------------------------------------------------------------------
+
+ def system_menu_shutdown(self):
+ logging.info("Emulator shutdown requested")
+ raise KeyboardInterrupt
+
+ def system_menu_reboot(self):
+ logging.info("Emulator: reboot is a no-op")
+
+ def system_menu_restart_sound(self):
+ logging.info("Emulator: restart sound is a no-op")
+
+ def system_menu_reload(self):
+ logging.info("Emulator: reload configs is a no-op")
+
+ # -------------------------------------------------------------------------
+ # Window integration — drain events every tick; couple LCD flush +
+ # window repaint to poll_lcd_updates so the emulator respects the main
+ # loop's gating instead of rendering at ~100 fps.
+ # -------------------------------------------------------------------------
+
+ def poll_controls(self):
+ if self._window is not None:
+ self._window.process_events()
+ super().poll_controls()
+
+ def poll_lcd_updates(self):
+ if self.lcd is not None:
+ self.lcd.poll_updates()
+ if self._window is not None:
+ self._window.render()
+
+
+ def cleanup(self):
+ super().cleanup()
+ import pygame
+ pygame.quit()
diff --git a/emulator/modhandler.py b/emulator/modhandler.py
new file mode 100644
index 00000000..a2af42c7
--- /dev/null
+++ b/emulator/modhandler.py
@@ -0,0 +1,113 @@
+# This file is part of pi-stomp.
+#
+# pi-stomp is free software: you can redistribute it and/or modify
+# it under the terms of the GNU General Public License as published by
+# the Free Software Foundation, either version 3 of the License, or
+# (at your option) any later version.
+#
+# pi-stomp is distributed in the hope that it will be useful,
+# but WITHOUT ANY WARRANTY; without even the implied warranty of
+# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+# GNU General Public License for more details.
+#
+# You should have received a copy of the GNU General Public License
+# along with pi-stomp. If not, see .
+
+"""EmulatorModhandler — Modhandler subclass for the v2/v3 emulator.
+
+Inherits the modern uilib/lcd320x240 handler from modalapi.Modhandler.
+Overrides Pi-only I/O (wifi, system info, shutdown/reboot) and wires
+pygame rendering into the poll loop.
+
+Requires MOD Desktop running locally at http://127.0.0.1:18181.
+"""
+
+import logging
+import os
+
+from modalapi.modhandler import Modhandler
+from emulator.stubs import VirtualAudiocard, StubWifiManager
+
+
+class EmulatorModhandler(Modhandler):
+
+ def __init__(self, homedir):
+ super().__init__(VirtualAudiocard(), homedir)
+
+ emu_data_dir = os.path.join(os.path.expanduser("~"), ".pistomp_emulator")
+ self.data_dir = emu_data_dir
+ self.banks_file = os.path.join(emu_data_dir, "banks.json")
+ self.pedalboard_modification_file = os.path.join(emu_data_dir, "last.json")
+ self.pedalboard_change_timestamp = 0
+ self.banks_file_timestamp = 0
+
+ self.root_uri = "http://127.0.0.1:18181/"
+ self.wifi_manager = StubWifiManager()
+
+ self._window = None
+
+ def set_window(self, window):
+ self._window = window
+
+ def pedalboard_change(self, pedalboard=None):
+ if pedalboard is None and self.pedalboard_list:
+ pedalboard = self.pedalboard_list[0]
+ super().pedalboard_change(pedalboard)
+ if pedalboard is not None:
+ self.set_current_pedalboard(pedalboard)
+
+ # -------------------------------------------------------------------------
+ # Skip Pi-only system calls
+ # -------------------------------------------------------------------------
+
+ def poll_wifi(self):
+ pass
+
+ def poll_system_info(self):
+ pass
+
+ def system_info_load(self):
+ self.eq_status = self.audiocard.get_switch_parameter(self.audiocard.DAC_EQ)
+ self.lcd.update_eq(self.eq_status)
+ self.bypass_left = self.audiocard.get_bypass_left()
+ self.bypass_right = self.audiocard.get_bypass_right()
+ self.lcd.update_bypass(self.bypass_left, self.bypass_right)
+
+ # -------------------------------------------------------------------------
+ # System menu: shutdown exits the emulator; everything else is a no-op
+ # -------------------------------------------------------------------------
+
+ def system_menu_shutdown(self, arg):
+ logging.info("Emulator shutdown requested")
+ raise KeyboardInterrupt
+
+ def system_menu_reboot(self, arg):
+ logging.info("Emulator: reboot is a no-op")
+
+ def system_menu_restart_sound(self, arg):
+ logging.info("Emulator: restart sound is a no-op")
+
+ def system_menu_reload(self, arg):
+ logging.info("Emulator: reload configs is a no-op")
+
+ def system_toggle_hotspot(self, **kwargs):
+ pass
+
+ def configure_wifi_credentials(self, ssid, password):
+ return None
+
+ # -------------------------------------------------------------------------
+ # Window integration — drain events and repaint every poll_controls tick
+ # (10 ms) to match the real device where lcd.update() is synchronous and
+ # each widget refresh is immediately visible. poll_lcd_updates is still
+ # called for the lcd_needs_update full-screen path (panel transitions).
+ # -------------------------------------------------------------------------
+
+ def poll_controls(self):
+ if self._window is not None:
+ self._window.process_events()
+ self._window.render()
+ super().poll_controls()
+
+ def poll_lcd_updates(self):
+ super().poll_lcd_updates()
diff --git a/emulator/stubs.py b/emulator/stubs.py
new file mode 100644
index 00000000..9df2d680
--- /dev/null
+++ b/emulator/stubs.py
@@ -0,0 +1,115 @@
+# This file is part of pi-stomp.
+#
+# pi-stomp is free software: you can redistribute it and/or modify
+# it under the terms of the GNU General Public License as published by
+# the Free Software Foundation, either version 3 of the License, or
+# (at your option) any later version.
+#
+# pi-stomp is distributed in the hope that it will be useful,
+# but WITHOUT ANY WARRANTY; without even the implied warranty of
+# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+# GNU General Public License for more details.
+#
+# You should have received a copy of the GNU General Public License
+# along with pi-stomp. If not, see .
+
+"""Hardware and system stubs shared across all emulator versions.
+
+VirtualAudiocard — in-memory audiocard; no ALSA/hardware access.
+StubWifiManager — no-op wifi; satisfies Mod/Modhandler's wifi_manager.
+StubRelay — no-op relay; satisfies the Relay interface without GPIO.
+"""
+
+
+from pistomp.audiocard import Audiocard
+
+
+class VirtualAudiocard(Audiocard):
+ """In-memory audiocard stub; holds EQ/volume/bypass state."""
+
+ CAPTURE_VOLUME = "capture_volume"
+ MASTER = "master_volume"
+ DAC_EQ = "dac_eq"
+ EQ_1 = "eq1"
+ EQ_2 = "eq2"
+ EQ_3 = "eq3"
+ EQ_4 = "eq4"
+ EQ_5 = "eq5"
+
+ def __init__(self):
+ self._volumes = {}
+ self._switches = {}
+ self._bypass_left = False
+ self._bypass_right = False
+
+ def get_volume_parameter(self, symbol):
+ return self._volumes.get(symbol, 0.0)
+
+ def set_volume_parameter(self, symbol, value):
+ self._volumes[symbol] = value
+
+ def get_switch_parameter(self, symbol):
+ return self._switches.get(symbol, False)
+
+ def set_switch_parameter(self, symbol, value):
+ self._switches[symbol] = value
+ return True
+
+ def get_bypass_left(self):
+ return self._bypass_left
+
+ def set_bypass_left(self, value):
+ self._bypass_left = value
+
+ def get_bypass_right(self):
+ return self._bypass_right
+
+ def set_bypass_right(self, value):
+ self._bypass_right = value
+
+ def set_output_muted(self, muted: bool) -> None:
+ pass
+
+
+class StubWifiManager:
+ """No-op wifi manager; satisfies the WifiManager interface."""
+
+ def poll(self):
+ return None
+
+ def get_ssid(self):
+ return None
+
+ def get_psk(self):
+ return None
+
+ def enable_hotspot(self):
+ pass
+
+ def disable_hotspot(self):
+ pass
+
+ def configure_wifi(self, ssid, password):
+ return None
+
+
+class StubRelay:
+ """No-op relay; satisfies the Relay interface without GPIO."""
+
+ def __init__(self):
+ self.enabled = True
+
+ def init_state(self):
+ return self.enabled
+
+ def enable(self):
+ self.enabled = True
+
+ def disable(self):
+ self.enabled = False
+
+ def update(self, enable):
+ self.enabled = enable
+
+ def get(self):
+ return self.enabled
diff --git a/emulator/window.py b/emulator/window.py
new file mode 100644
index 00000000..3db8e2cf
--- /dev/null
+++ b/emulator/window.py
@@ -0,0 +1,394 @@
+# This file is part of pi-stomp.
+#
+# pi-stomp is free software: you can redistribute it and/or modify
+# it under the terms of the GNU General Public License as published by
+# the Free Software Foundation, either version 3 of the License, or
+# (at your option) any later version.
+#
+# pi-stomp is distributed in the hope that it will be useful,
+# but WITHOUT ANY WARRANTY; without even the implied warranty of
+# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+# GNU General Public License for more details.
+#
+# You should have received a copy of the GNU General Public License
+# along with pi-stomp. If not, see .
+
+"""EmulatorWindow — pygame window that shows the 320×240 LCD (2× scaled)
+alongside clickable representations of the physical controls.
+
+Layout (total 940 × 500):
+ ┌────────────────────────┬────────────────────────┐
+ │ LCD 640×480 (2×) │ Controls panel 300px │
+ └────────────────────────┴────────────────────────┘
+
+Keyboard shortcuts (availability depends on hardware version)
+ ← / → nav encoder left / right
+ Enter / Space nav encoder press (click)
+ L nav encoder long-press
+ 1 / 2 / 3 / 4 footswitch 1 / 2 / 3 / 4
+ Q / W tweak enc 1 left / right E = press
+ A / S tweak enc 2 left / right D = press
+ Z / X volume enc left / right
+ ↑ / ↓ expression pedal +/-
+ Esc quit
+"""
+
+import os
+import pygame
+import pygame._freetype as _freetype
+import pistomp.switchstate as switchstate
+
+_FONTS_DIR = os.path.join(os.path.dirname(os.path.dirname(os.path.abspath(__file__))), "fonts")
+_FONT_MONO = os.path.join(_FONTS_DIR, "DejaVuSansMono.ttf")
+_FONT_MONO_BOLD = os.path.join(_FONTS_DIR, "DejaVuSansMono-Bold.ttf")
+
+
+class _FTFont:
+ """Wraps pygame._freetype.Font to match the pygame.font.Font render API."""
+
+ def __init__(self, path, size):
+ self._ft = _freetype.Font(path, size)
+
+ def render(self, text, antialias, color):
+ surf, _ = self._ft.render(text, color)
+ return surf
+
+# ---- dimensions (per-instance; these module-level values are defaults) ------
+CTRL_W = 300
+_TARGET_H = 480 # desired display area height — scale is computed to match
+
+# ---- colours ----------------------------------------------------------------
+BG = (30, 30, 30)
+PANEL_BG = (45, 45, 45)
+BTN_IDLE = (80, 80, 80)
+BTN_HOVER = (120, 120, 120)
+BTN_ACTIVE = (200, 200, 200)
+FS_ON = (0, 200, 80)
+FS_OFF = (80, 80, 80)
+TEXT_COLOR = (220, 220, 220)
+DIM_TEXT = (130, 130, 130)
+SLIDER_BG = (60, 60, 60)
+SLIDER_FG = (0, 160, 200)
+
+
+class _Label:
+ """Non-interactive text drawn on the controls panel."""
+
+ def __init__(self, pos, text, font, color=DIM_TEXT):
+ self._surf = font.render(text, True, color)
+ self._pos = pos
+
+ def draw(self, surf):
+ surf.blit(self._surf, self._pos)
+
+
+class _Btn:
+ """Simple clickable rectangle."""
+
+ def __init__(self, rect, label, action, font):
+ self.rect = pygame.Rect(rect)
+ self.label = label
+ self.action = action
+ self.font = font
+ self._hover = False
+
+ def draw(self, surf, active=False):
+ color = BTN_ACTIVE if active else (BTN_HOVER if self._hover else BTN_IDLE)
+ pygame.draw.rect(surf, color, self.rect, border_radius=4)
+ text = self.font.render(self.label, True, (0, 0, 0) if active else TEXT_COLOR)
+ tr = text.get_rect(center=self.rect.center)
+ surf.blit(text, tr)
+
+ def handle_event(self, event):
+ if event.type == pygame.MOUSEMOTION:
+ self._hover = self.rect.collidepoint(event.pos)
+ elif event.type == pygame.MOUSEBUTTONDOWN and event.button == 1:
+ if self.rect.collidepoint(event.pos):
+ self.action()
+
+
+class EmulatorWindow:
+
+ def __init__(self, hardware):
+ self.hw = hardware
+ self.running = True
+
+ lcd_w = hardware.lcd_pygame.width
+ lcd_h = hardware.lcd_pygame.height
+ scale = max(1, _TARGET_H // lcd_h)
+ self.lcd_disp_w = lcd_w * scale
+ self.lcd_disp_h = lcd_h * scale
+ self.win_w = self.lcd_disp_w + CTRL_W
+ self.win_h = self.lcd_disp_h
+ self.ctrl_x = self.lcd_disp_w + 10
+
+ self.screen = pygame.display.set_mode((self.win_w, self.win_h))
+ pygame.key.set_repeat(300, 50)
+ version_label = getattr(hardware, 'VERSION_LABEL', '')
+ title = "pi-Stomp Emulator (%s)" % version_label if version_label else "pi-Stomp Emulator"
+ pygame.display.set_caption(title)
+
+ self.font_sm = _FTFont(_FONT_MONO, 13)
+ self.font_med = _FTFont(_FONT_MONO_BOLD, 15)
+ self.font_hdr = _FTFont(_FONT_MONO_BOLD, 14)
+
+ self._exp_value = 64 # 0-127 MIDI value for expression pedal
+ self._exp_dragging = False
+
+ self._buttons: list[_Btn] = []
+ self._fs_btns: list[tuple[_Btn, int]] = []
+ self._labels: list[_Label] = []
+ self._build_ui()
+
+ # -------------------------------------------------------------------------
+ # UI construction
+ # -------------------------------------------------------------------------
+
+ def _build_ui(self):
+ y = 15
+ bw, bh = 60, 30 # default button size
+
+ # --- Footswitches ----------------------------------------------------
+ num_fs = len(self.hw.footswitches)
+ fs_spacing = min(68, (CTRL_W - 20) // max(num_fs, 1))
+ for i, fs in enumerate(self.hw.footswitches):
+ x = self.ctrl_x + 5 + i * fs_spacing
+ idx = i
+ btn = _Btn((x, y, 56, 46),
+ "FS%d" % (i + 1),
+ lambda fs=fs: fs.press(),
+ self.font_med)
+ self._buttons.append(btn)
+ self._fs_btns.append((btn, idx))
+
+ y += 60
+
+ # --- Encoders --------------------------------------------------------
+ enc_y = y
+ for enc in self.hw.encoders:
+ label = self._enc_label(enc)
+ enc_y = self._add_encoder_row(enc, label, enc_y)
+ enc_y += 8
+
+ self._exp_slider_y = enc_y + 10
+ self._exp_slider_rect = pygame.Rect(
+ self.ctrl_x + 5, self._exp_slider_y + 16, CTRL_W - 20, 12)
+
+ def _enc_label(self, enc):
+ if hasattr(enc, 'midi_CC') and enc.midi_CC is not None:
+ return "Enc %s (CC%d)" % (enc.id, enc.midi_CC)
+ if getattr(enc, 'type', None) == 'VOLUME':
+ return "Vol (enc %s)" % enc.id
+ if getattr(enc, 'label', None) is not None:
+ return enc.label
+ return "Nav"
+
+ def _add_encoder_row(self, enc, label, y):
+ self._labels.append(_Label((self.ctrl_x + 5, y), label, self.font_sm))
+ y += 15
+
+ bw, bh = 38, 28
+ has_press = getattr(enc, 'press_callback', None) is not None
+
+ left_x = self.ctrl_x + 5
+ mid_x = left_x + bw + 4
+ right_x = mid_x + (bw + 4 if has_press else 0)
+
+ self._buttons.append(_Btn(
+ (left_x, y, bw, bh), "◄",
+ lambda e=enc: e.step(-1), self.font_med))
+
+ if has_press:
+ self._buttons.append(_Btn(
+ (mid_x, y, bw, bh), "●",
+ lambda e=enc: e.press(switchstate.Value.RELEASED),
+ self.font_med))
+ self._buttons.append(_Btn(
+ (right_x, y, bw, bh), "►",
+ lambda e=enc: e.step(1), self.font_med))
+ else:
+ self._buttons.append(_Btn(
+ (mid_x, y, bw, bh), "►",
+ lambda e=enc: e.step(1), self.font_med))
+
+ return y + bh + 2
+
+ # -------------------------------------------------------------------------
+ # Main loop integration
+ # -------------------------------------------------------------------------
+
+ def process_events(self):
+ for event in pygame.event.get():
+ if event.type == pygame.QUIT:
+ raise KeyboardInterrupt
+
+ for btn in self._buttons:
+ btn.handle_event(event)
+
+ if event.type == pygame.KEYDOWN:
+ self._handle_key(event.key, event.mod)
+
+ if event.type == pygame.MOUSEBUTTONDOWN and event.button == 1:
+ if self._exp_slider_rect.collidepoint(event.pos):
+ self._exp_dragging = True
+ self._update_exp_from_mouse(event.pos[0])
+
+ if event.type == pygame.MOUSEBUTTONUP and event.button == 1:
+ self._exp_dragging = False
+
+ if event.type == pygame.MOUSEMOTION and self._exp_dragging:
+ self._update_exp_from_mouse(event.pos[0])
+
+ def render(self):
+ self.screen.fill(BG)
+
+ # LCD (scaled 2×)
+ self.hw.lcd_pygame.blit_scaled(self.screen, pygame.Rect(0, 0, self.lcd_disp_w, self.lcd_disp_h))
+
+ # Controls panel background
+ panel_rect = pygame.Rect(self.lcd_disp_w, 0, CTRL_W, self.win_h)
+ pygame.draw.rect(self.screen, PANEL_BG, panel_rect)
+
+ # Footswitch state colours
+ for btn, idx in self._fs_btns:
+ fs = self.hw.footswitches[idx]
+ color = FS_ON if fs.enabled else FS_OFF
+ pygame.draw.rect(self.screen, color, btn.rect, border_radius=4)
+ lbl = fs.get_display_label() or ("FS%d" % (idx + 1))
+ text = self.font_med.render(lbl[:6], True, TEXT_COLOR)
+ tr = text.get_rect(center=btn.rect.center)
+ self.screen.blit(text, tr)
+
+ # All other buttons and encoder header labels
+ fs_btn_set = {id(b) for b, _ in self._fs_btns}
+ for btn in self._buttons:
+ if id(btn) not in fs_btn_set:
+ btn.draw(self.screen)
+ for lbl in self._labels:
+ lbl.draw(self.screen)
+
+ # Expression pedal
+ if self.hw.analog_controls:
+ self._draw_exp_slider()
+
+ # Keyboard hints (bottom of panel) — only show what's wired up
+ hints = ["← → nav Enter=click L=long"]
+ num_fs = len(self.hw.footswitches)
+ if num_fs:
+ hints.append("1-%d footswitches" % num_fs)
+ tweak = getattr(self.hw, 'tweak_encoders', [])
+ vol = getattr(self.hw, 'volume_encoder', None)
+ if len(tweak) >= 1:
+ hints.append("Q/W enc1 E=press")
+ if len(tweak) >= 2:
+ hints[-1] += " A/S enc2 D=press"
+ if vol is not None:
+ hints.append("Z/X vol enc")
+ if self.hw.analog_controls:
+ hints.append("↑↓ expr pedal Esc=quit")
+ else:
+ hints.append("Esc=quit")
+
+ hy = self.win_h - len(hints) * 16 - 5
+ for h in hints:
+ surf = self.font_sm.render(h, True, DIM_TEXT)
+ self.screen.blit(surf, (self.ctrl_x + 4, hy))
+ hy += 16
+
+ pygame.display.flip()
+
+ def _draw_exp_slider(self):
+ ctrl = self.hw.analog_controls[0]
+ y = self._exp_slider_y
+ lbl = self.font_sm.render("Expr (CC%s)" % ctrl.midi_CC, True, DIM_TEXT)
+ self.screen.blit(lbl, (self.ctrl_x + 5, y))
+
+ r = self._exp_slider_rect
+ pygame.draw.rect(self.screen, SLIDER_BG, r, border_radius=4)
+ fill_w = int(r.width * self._exp_value / 127)
+ if fill_w > 0:
+ pygame.draw.rect(self.screen, SLIDER_FG,
+ (r.x, r.y, fill_w, r.height), border_radius=4)
+ tx = r.x + fill_w
+ pygame.draw.circle(self.screen, TEXT_COLOR, (tx, r.centery), 7)
+
+ # -------------------------------------------------------------------------
+ # Input handling
+ # -------------------------------------------------------------------------
+
+ def _handle_key(self, key, mod):
+ nav = getattr(self.hw, 'nav_encoder', None)
+ tweak = getattr(self.hw, 'tweak_encoders', [])
+ vol = getattr(self.hw, 'volume_encoder', None)
+
+ if key == pygame.K_ESCAPE:
+ raise KeyboardInterrupt
+
+ # Nav encoder
+ elif key == pygame.K_LEFT and nav:
+ nav.step(-1)
+ elif key == pygame.K_RIGHT and nav:
+ nav.step(1)
+ elif key in (pygame.K_RETURN, pygame.K_SPACE) and nav:
+ nav.press(switchstate.Value.RELEASED)
+ elif key == pygame.K_l and nav:
+ nav.press(switchstate.Value.LONGPRESSED)
+
+ # Footswitches
+ elif key in (pygame.K_1, pygame.K_KP1):
+ self._press_fs(0)
+ elif key in (pygame.K_2, pygame.K_KP2):
+ self._press_fs(1)
+ elif key in (pygame.K_3, pygame.K_KP3):
+ self._press_fs(2)
+ elif key in (pygame.K_4, pygame.K_KP4):
+ self._press_fs(3)
+
+ # Tweak encoder 1
+ elif key == pygame.K_q and len(tweak) >= 1:
+ tweak[0].step(-1)
+ elif key == pygame.K_w and len(tweak) >= 1:
+ tweak[0].step(1)
+ elif key == pygame.K_e and len(tweak) >= 1:
+ tweak[0].press(switchstate.Value.RELEASED)
+
+ # Tweak encoder 2
+ elif key == pygame.K_a and len(tweak) >= 2:
+ tweak[1].step(-1)
+ elif key == pygame.K_s and len(tweak) >= 2:
+ tweak[1].step(1)
+ elif key == pygame.K_d and len(tweak) >= 2:
+ tweak[1].press(switchstate.Value.RELEASED)
+
+ # Volume encoder
+ elif key == pygame.K_z and vol:
+ vol.step(-1)
+ elif key == pygame.K_x and vol:
+ vol.step(1)
+
+ # Expression pedal
+ elif key == pygame.K_UP:
+ self._nudge_exp(5)
+ elif key == pygame.K_DOWN:
+ self._nudge_exp(-5)
+
+ def _press_fs(self, index):
+ if index < len(self.hw.footswitches):
+ self.hw.footswitches[index].press()
+
+ def _nudge_exp(self, delta):
+ if not self.hw.analog_controls:
+ return
+ self._exp_value = max(0, min(127, self._exp_value + delta))
+ ctrl = self.hw.analog_controls[0]
+ ctrl.set_value(self._exp_value)
+ ctrl.send_midi(self._exp_value)
+
+ def _update_exp_from_mouse(self, mouse_x):
+ r = self._exp_slider_rect
+ ratio = (mouse_x - r.x) / r.width
+ self._exp_value = int(max(0.0, min(1.0, ratio)) * 127)
+ if self.hw.analog_controls:
+ ctrl = self.hw.analog_controls[0]
+ ctrl.set_value(self._exp_value)
+ ctrl.send_midi(self._exp_value)
diff --git a/fonts/DejaVuSans-Bold.ttf b/fonts/DejaVuSans-Bold.ttf
new file mode 100644
index 00000000..6d65fa7d
Binary files /dev/null and b/fonts/DejaVuSans-Bold.ttf differ
diff --git a/fonts/DejaVuSans.ttf b/fonts/DejaVuSans.ttf
new file mode 100644
index 00000000..e5f7eecc
Binary files /dev/null and b/fonts/DejaVuSans.ttf differ
diff --git a/fonts/DejaVuSansMono-Bold.ttf b/fonts/DejaVuSansMono-Bold.ttf
new file mode 100644
index 00000000..8184ced8
Binary files /dev/null and b/fonts/DejaVuSansMono-Bold.ttf differ
diff --git a/fonts/DejaVuSansMono.ttf b/fonts/DejaVuSansMono.ttf
new file mode 100644
index 00000000..f5786022
Binary files /dev/null and b/fonts/DejaVuSansMono.ttf differ
diff --git a/fonts/LICENSE-DejaVu.txt b/fonts/LICENSE-DejaVu.txt
new file mode 100644
index 00000000..df52c170
--- /dev/null
+++ b/fonts/LICENSE-DejaVu.txt
@@ -0,0 +1,187 @@
+Fonts are (c) Bitstream (see below). DejaVu changes are in public domain.
+Glyphs imported from Arev fonts are (c) Tavmjong Bah (see below)
+
+
+Bitstream Vera Fonts Copyright
+------------------------------
+
+Copyright (c) 2003 by Bitstream, Inc. All Rights Reserved. Bitstream Vera is
+a trademark of Bitstream, Inc.
+
+Permission is hereby granted, free of charge, to any person obtaining a copy
+of the fonts accompanying this license ("Fonts") and associated
+documentation files (the "Font Software"), to reproduce and distribute the
+Font Software, including without limitation the rights to use, copy, merge,
+publish, distribute, and/or sell copies of the Font Software, and to permit
+persons to whom the Font Software is furnished to do so, subject to the
+following conditions:
+
+The above copyright and trademark notices and this permission notice shall
+be included in all copies of one or more of the Font Software typefaces.
+
+The Font Software may be modified, altered, or added to, and in particular
+the designs of glyphs or characters in the Fonts may be modified and
+additional glyphs or characters may be added to the Fonts, only if the fonts
+are renamed to names not containing either the words "Bitstream" or the word
+"Vera".
+
+This License becomes null and void to the extent applicable to Fonts or Font
+Software that has been modified and is distributed under the "Bitstream
+Vera" names.
+
+The Font Software may be sold as part of a larger software package but no
+copy of one or more of the Font Software typefaces may be sold by itself.
+
+THE FONT SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS
+OR IMPLIED, INCLUDING BUT NOT LIMITED TO ANY WARRANTIES OF MERCHANTABILITY,
+FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT OF COPYRIGHT, PATENT,
+TRADEMARK, OR OTHER RIGHT. IN NO EVENT SHALL BITSTREAM OR THE GNOME
+FOUNDATION BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, INCLUDING
+ANY GENERAL, SPECIAL, INDIRECT, INCIDENTAL, OR CONSEQUENTIAL DAMAGES,
+WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF
+THE USE OR INABILITY TO USE THE FONT SOFTWARE OR FROM OTHER DEALINGS IN THE
+FONT SOFTWARE.
+
+Except as contained in this notice, the names of Gnome, the Gnome
+Foundation, and Bitstream Inc., shall not be used in advertising or
+otherwise to promote the sale, use or other dealings in this Font Software
+without prior written authorization from the Gnome Foundation or Bitstream
+Inc., respectively. For further information, contact: fonts at gnome dot
+org.
+
+Arev Fonts Copyright
+------------------------------
+
+Copyright (c) 2006 by Tavmjong Bah. All Rights Reserved.
+
+Permission is hereby granted, free of charge, to any person obtaining
+a copy of the fonts accompanying this license ("Fonts") and
+associated documentation files (the "Font Software"), to reproduce
+and distribute the modifications to the Bitstream Vera Font Software,
+including without limitation the rights to use, copy, merge, publish,
+distribute, and/or sell copies of the Font Software, and to permit
+persons to whom the Font Software is furnished to do so, subject to
+the following conditions:
+
+The above copyright and trademark notices and this permission notice
+shall be included in all copies of one or more of the Font Software
+typefaces.
+
+The Font Software may be modified, altered, or added to, and in
+particular the designs of glyphs or characters in the Fonts may be
+modified and additional glyphs or characters may be added to the
+Fonts, only if the fonts are renamed to names not containing either
+the words "Tavmjong Bah" or the word "Arev".
+
+This License becomes null and void to the extent applicable to Fonts
+or Font Software that has been modified and is distributed under the
+"Tavmjong Bah Arev" names.
+
+The Font Software may be sold as part of a larger software package but
+no copy of one or more of the Font Software typefaces may be sold by
+itself.
+
+THE FONT SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND,
+EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO ANY WARRANTIES OF
+MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT
+OF COPYRIGHT, PATENT, TRADEMARK, OR OTHER RIGHT. IN NO EVENT SHALL
+TAVMJONG BAH BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY,
+INCLUDING ANY GENERAL, SPECIAL, INDIRECT, INCIDENTAL, OR CONSEQUENTIAL
+DAMAGES, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING
+FROM, OUT OF THE USE OR INABILITY TO USE THE FONT SOFTWARE OR FROM
+OTHER DEALINGS IN THE FONT SOFTWARE.
+
+Except as contained in this notice, the name of Tavmjong Bah shall not
+be used in advertising or otherwise to promote the sale, use or other
+dealings in this Font Software without prior written authorization
+from Tavmjong Bah. For further information, contact: tavmjong @ free
+. fr.
+
+TeX Gyre DJV Math
+-----------------
+Fonts are (c) Bitstream (see below). DejaVu changes are in public domain.
+
+Math extensions done by B. Jackowski, P. Strzelczyk and P. Pianowski
+(on behalf of TeX users groups) are in public domain.
+
+Letters imported from Euler Fraktur from AMSfonts are (c) American
+Mathematical Society (see below).
+Bitstream Vera Fonts Copyright
+Copyright (c) 2003 by Bitstream, Inc. All Rights Reserved. Bitstream Vera
+is a trademark of Bitstream, Inc.
+
+Permission is hereby granted, free of charge, to any person obtaining a copy
+of the fonts accompanying this license (“Fonts”) and associated
+documentation
+files (the “Font Software”), to reproduce and distribute the Font Software,
+including without limitation the rights to use, copy, merge, publish,
+distribute,
+and/or sell copies of the Font Software, and to permit persons to whom
+the Font Software is furnished to do so, subject to the following
+conditions:
+
+The above copyright and trademark notices and this permission notice
+shall be
+included in all copies of one or more of the Font Software typefaces.
+
+The Font Software may be modified, altered, or added to, and in particular
+the designs of glyphs or characters in the Fonts may be modified and
+additional
+glyphs or characters may be added to the Fonts, only if the fonts are
+renamed
+to names not containing either the words “Bitstream” or the word “Vera”.
+
+This License becomes null and void to the extent applicable to Fonts or
+Font Software
+that has been modified and is distributed under the “Bitstream Vera”
+names.
+
+The Font Software may be sold as part of a larger software package but
+no copy
+of one or more of the Font Software typefaces may be sold by itself.
+
+THE FONT SOFTWARE IS PROVIDED “AS IS”, WITHOUT WARRANTY OF ANY KIND, EXPRESS
+OR IMPLIED, INCLUDING BUT NOT LIMITED TO ANY WARRANTIES OF MERCHANTABILITY,
+FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT OF COPYRIGHT, PATENT,
+TRADEMARK, OR OTHER RIGHT. IN NO EVENT SHALL BITSTREAM OR THE GNOME
+FOUNDATION
+BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, INCLUDING ANY GENERAL,
+SPECIAL, INDIRECT, INCIDENTAL, OR CONSEQUENTIAL DAMAGES, WHETHER IN AN
+ACTION
+OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF THE USE OR
+INABILITY TO USE
+THE FONT SOFTWARE OR FROM OTHER DEALINGS IN THE FONT SOFTWARE.
+Except as contained in this notice, the names of GNOME, the GNOME
+Foundation,
+and Bitstream Inc., shall not be used in advertising or otherwise to promote
+the sale, use or other dealings in this Font Software without prior written
+authorization from the GNOME Foundation or Bitstream Inc., respectively.
+For further information, contact: fonts at gnome dot org.
+
+AMSFonts (v. 2.2) copyright
+
+The PostScript Type 1 implementation of the AMSFonts produced by and
+previously distributed by Blue Sky Research and Y&Y, Inc. are now freely
+available for general use. This has been accomplished through the
+cooperation
+of a consortium of scientific publishers with Blue Sky Research and Y&Y.
+Members of this consortium include:
+
+Elsevier Science IBM Corporation Society for Industrial and Applied
+Mathematics (SIAM) Springer-Verlag American Mathematical Society (AMS)
+
+In order to assure the authenticity of these fonts, copyright will be
+held by
+the American Mathematical Society. This is not meant to restrict in any way
+the legitimate use of the fonts, such as (but not limited to) electronic
+distribution of documents containing these fonts, inclusion of these fonts
+into other public domain or commercial font collections or computer
+applications, use of the outline data to create derivative fonts and/or
+faces, etc. However, the AMS does require that the AMS copyright notice be
+removed from any derivative versions of the fonts which have been altered in
+any way. In addition, to ensure the fidelity of TeX documents using Computer
+Modern fonts, Professor Donald Knuth, creator of the Computer Modern faces,
+has requested that any alterations which yield different font metrics be
+given a different name.
+
+$Id$
diff --git a/modalapi/mod.py b/modalapi/mod.py
index efeedc66..51603128 100755
--- a/modalapi/mod.py
+++ b/modalapi/mod.py
@@ -491,7 +491,7 @@ def load_pedalboards(self):
logging.info("Loading pedalboard info: %s" % pb[Token.TITLE])
bundle = pb[Token.BUNDLE]
title = pb[Token.TITLE]
- pedalboard = Pedalboard.Pedalboard(title, bundle)
+ pedalboard = Pedalboard.Pedalboard(title, bundle, root_uri=self.root_uri)
pedalboard.load_bundle(bundle, self.plugin_dict)
self.pedalboards[bundle] = pedalboard
self.pedalboard_list.append(pedalboard)
diff --git a/modalapi/modhandler.py b/modalapi/modhandler.py
index 42fb73e0..5c5cf4d0 100755
--- a/modalapi/modhandler.py
+++ b/modalapi/modhandler.py
@@ -14,6 +14,7 @@
# along with pi-stomp. If not, see .
from pistomp.handler import Handler
+from pistomp.audiocard import Audiocard
import json
import logging
@@ -43,7 +44,7 @@
class Modhandler(Handler):
__single = None
- def __init__(self, audiocard, homedir):
+ def __init__(self, audiocard: Audiocard, homedir):
logging.info("Init modhandler")
if Modhandler.__single:
raise RuntimeError("Attempt to create second Modhandler singleton", Modhandler.__single)
@@ -314,7 +315,7 @@ def load_pedalboards(self):
logging.info("Loading pedalboard info: %s" % pb[Token.TITLE])
bundle = pb[Token.BUNDLE]
title = pb[Token.TITLE]
- pedalboard = Pedalboard.Pedalboard(title, bundle)
+ pedalboard = Pedalboard.Pedalboard(title, bundle, root_uri=self.root_uri)
pedalboard.load_bundle(bundle, self.plugin_dict)
self.pedalboards[bundle] = pedalboard
self.pedalboard_list.append(pedalboard)
@@ -326,7 +327,7 @@ def reload_pedalboard(self, bundle):
title = old.title
# create a new one
- pedalboard = Pedalboard.Pedalboard(title, bundle)
+ pedalboard = Pedalboard.Pedalboard(title, bundle, root_uri=self.root_uri)
pedalboard.load_bundle(bundle, self.plugin_dict)
self.pedalboards[bundle] = pedalboard
diff --git a/modalapi/pedalboard.py b/modalapi/pedalboard.py
index f2dc8ed3..661c5303 100755
--- a/modalapi/pedalboard.py
+++ b/modalapi/pedalboard.py
@@ -29,8 +29,8 @@
class Pedalboard:
- def __init__(self, title, bundle):
- self.root_uri = "http://localhost:80/"
+ def __init__(self, title, bundle, root_uri="http://localhost:80/"):
+ self.root_uri = root_uri
self.title = title
self.bundle = bundle # TODO used?
self.plugins = []
diff --git a/modalapistomp.py b/modalapistomp.py
index 92fee74d..d6c24594 100755
--- a/modalapistomp.py
+++ b/modalapistomp.py
@@ -18,6 +18,7 @@
# Configure logging BEFORE any imports to ensure it takes effect
import logging
import sys
+from typing import Any
# Set up logging with format that works well with systemd journal
logging.basicConfig(
@@ -32,6 +33,7 @@
from rtmidi.midiutil import open_midioutput
+from pistomp.audiocard import Audiocard
import pistomp.audiocardfactory as Audiocardfactory
import pistomp.config as config
import pistomp.generichost as Generichost
@@ -39,6 +41,7 @@
import pistomp.handlerfactory as Handlerfactory
import pistomp.hardwarefactory as Hardwarefactory
+EMULATOR_HOSTS = ("emulator_v1", "emulator_v2", "emulator_v3")
def main():
sys.settrace
@@ -58,7 +61,7 @@ def main():
nargs="+",
help="Plugin host to use. Example --host mod'",
default=["mod"],
- choices=["mod", "mod1", "generic", "test"],
+ choices=["mod", "mod1", "generic", "test", "emulator_v1", "emulator_v2", "emulator_v3"],
)
args = parser.parse_args()
@@ -83,31 +86,38 @@ def main():
# Current Working Dir
cwd = os.path.dirname(os.path.realpath(__file__))
- # Audio Card Config - doing this early so audio passes ASAP
- factory = Audiocardfactory.Audiocardfactory(cwd)
- audiocard = factory.create()
- audiocard.restore()
-
- # MIDI initialization
- # Prompts user for MIDI input port, unless a valid port number or name
- # is given as the first argument on the command line.
- # API backend defaults to ALSA on Linux.
- # TODO discover and use the thru port (seems to be 14:0 on my system)
- # shouldn't need to aconnect, just send msgs directly to the thru port
- port = 0 # TODO get this (the Midi Through port) programmatically
- # port = sys.argv[1] if len(sys.argv) > 1 else None
- try:
- midiout, port_name = open_midioutput(port)
- except (EOFError, KeyboardInterrupt):
- sys.exit()
-
# Handler object
handler = None
+ midiout = None
+
+ cfg: dict[str, Any] | None = None
+ audiocard: Audiocard | None = None
+
+ is_emulator = args.host[0] in EMULATOR_HOSTS
+
+ if not is_emulator:
+ # Audio Card Config - doing this early so audio passes ASAP
+ factory = Audiocardfactory.Audiocardfactory(cwd)
+ audiocard = factory.create()
+ audiocard.restore()
+
+ # MIDI initialization
+ # Prompts user for MIDI input port, unless a valid port number or name
+ # is given as the first argument on the command line.
+ # API backend defaults to ALSA on Linux.
+ # TODO discover and use the thru port (seems to be 14:0 on my system)
+ # shouldn't need to aconnect, just send msgs directly to the thru port
+ port = 0 # TODO get this (the Midi Through port) programmatically
+ # port = sys.argv[1] if len(sys.argv) > 1 else None
+ try:
+ midiout, port_name = open_midioutput(port)
+ except (EOFError, KeyboardInterrupt):
+ sys.exit()
- # Load the default config
- # cfg used by factories to determine which handler and hardware objects to create
- # Hardware object uses cfg to know how to initialize the hardware elements
- cfg = config.load_default_cfg()
+ # Load the default config
+ # cfg used by factories to determine which handler and hardware objects to create
+ # Hardware object uses cfg to know how to initialize the hardware elements
+ cfg = config.load_default_cfg()
if args.host[0] == "mod":
# Create singleton Mod handler
@@ -155,7 +165,12 @@ def main():
except:
raise
+ elif is_emulator:
+ from emulator.bootstrap import bootstrap_emulator
+ handler, midiout = bootstrap_emulator(args.host[0], cwd)
+
assert handler is not None
+
logging.info("Entering main loop. Press Control-C to exit.")
period = 0
try:
@@ -185,7 +200,8 @@ def main():
logging.info("keyboard interrupt")
finally:
logging.info("Exit.")
- midiout.close_port()
+ if midiout:
+ midiout.close_port()
handler.cleanup()
del handler
logging.info("Completed cleanup")
diff --git a/pistomp/analogcontrol.py b/pistomp/analogcontrol.py
index 5632a084..bd4a57d0 100755
--- a/pistomp/analogcontrol.py
+++ b/pistomp/analogcontrol.py
@@ -13,12 +13,7 @@
# You should have received a copy of the GNU General Public License
# along with pi-stomp. If not, see .
-import busio
-import digitalio
-import board
-import adafruit_mcp3xxx.mcp3008 as MCP
import logging
-from adafruit_mcp3xxx.analog_in import AnalogIn
class AnalogControl:
diff --git a/pistomp/audiocardfactory.py b/pistomp/audiocardfactory.py
index ef231284..8db09080 100644
--- a/pistomp/audiocardfactory.py
+++ b/pistomp/audiocardfactory.py
@@ -16,6 +16,7 @@
import pistomp.audioinjector
import pistomp.hifiberry
import pistomp.iqaudiocodec
+import pistomp.audiocard as audiocard
from pathlib import Path
@@ -45,7 +46,7 @@ def get_current_card(self):
f.close()
return result
- def create(self):
+ def create(self) -> audiocard.Audiocard:
# get the current card
card_name = self.get_current_card()
if card_name == "IQaudIOCODEC":
diff --git a/pistomp/config.py b/pistomp/config.py
index 60dd5645..4a9b9869 100644
--- a/pistomp/config.py
+++ b/pistomp/config.py
@@ -15,6 +15,7 @@
import logging
import os
+from typing import Any
import yaml
from jsonschema import validate
@@ -176,7 +177,19 @@
]
}
-def load_default_cfg():
+def load_cfg_from_file(path):
+ """Load and validate a config from an explicit file path."""
+ with open(path, 'r') as ymlfile:
+ cfg = yaml.load(ymlfile, Loader=yaml.SafeLoader)
+ try:
+ validate(instance=cfg, schema=schema)
+ except exceptions.SchemaError as e:
+ logging.error("Badly formatted schema in: %s %s" % (os.path.basename(path), e.message))
+ except exceptions.ValidationError as e:
+ logging.error("Config file error in: %s\n%s\n%s" % (path, e.schema_path, e.message))
+ return cfg
+
+def load_default_cfg() -> dict[str, Any]:
# Read the default config file - should only need to read once per session
default_config_file = os.path.join(data_dir, DEFAULT_CONFIG_FILE)
with open(default_config_file, 'r') as ymlfile:
diff --git a/pistomp/encoder.py b/pistomp/encoder.py
index 37e622a3..51557fae 100755
--- a/pistomp/encoder.py
+++ b/pistomp/encoder.py
@@ -16,7 +16,6 @@
import threading
from functools import partial
-from gpiozero import Button # TODO consider using Encoder class instead
class Encoder:
@@ -61,12 +60,16 @@ def __init__(self, d_pin, clk_pin, callback, type=None, id=None, **kw):
# It works fine without a lock since this is just dumb UI, but let's be correct..
self._lock = threading.Lock()
- self.data = Button(d_pin)
- self.data.when_pressed = self._gpio_callback
- self.data.when_released = self._gpio_callback
- self.clk = Button(clk_pin)
- self.clk.when_pressed = self._gpio_callback
- self.clk.when_released = self._gpio_callback
+ self.data = None
+ self.clk = None
+ if d_pin is not None:
+ from gpiozero import Button # TODO consider using Encoder class instead
+ self.data = Button(d_pin)
+ self.data.when_pressed = self._gpio_callback
+ self.data.when_released = self._gpio_callback
+ self.clk = Button(clk_pin)
+ self.clk.when_pressed = self._gpio_callback
+ self.clk.when_released = self._gpio_callback
self.prevNextCode = 0
self.store = 0
@@ -78,8 +81,10 @@ def __init__(self, d_pin, clk_pin, callback, type=None, id=None, **kw):
super(Encoder, self).__init__(**kw)
def __del__(self):
- self.data.close()
- self.clk.close()
+ if self.data is not None:
+ self.data.close()
+ if self.clk is not None:
+ self.clk.close()
def get_data(self):
return self.data.value
diff --git a/pistomp/footswitch.py b/pistomp/footswitch.py
index 83bda633..db8cca27 100755
--- a/pistomp/footswitch.py
+++ b/pistomp/footswitch.py
@@ -15,7 +15,6 @@
import logging
import time
-import gpiozero as GPIO
import sys
from rtmidi.midiconstants import CONTROL_CHANGE
@@ -117,6 +116,7 @@ def __init__(self, id, led_pin, pixel, midi_CC, midi_channel, midiout, refresh_c
if led_pin is not None:
try:
+ import gpiozero as GPIO
self.led = GPIO.LED(led_pin)
except Exception as e:
logging.error("Initializing LED for footswitch %d: %s" % (id, str(e)))
diff --git a/pistomp/gpioswitch.py b/pistomp/gpioswitch.py
index ec6ee200..a9e41d02 100755
--- a/pistomp/gpioswitch.py
+++ b/pistomp/gpioswitch.py
@@ -14,7 +14,6 @@
# along with pi-stomp. If not, see .
import logging
-from gpiozero import Button
import pistomp.controller as controller
import pistomp.switchstate as switchstate
@@ -40,6 +39,7 @@ def __init__(self, gpio_input, midi_channel, midi_CC, callback, longpress_callba
# TODO with the move to gpiozero.button, we could take advantage of its methods for detecting release,
# hold, etc. (when_released, when_held). But experiments with those async events caused issues with
# the LCD refresh timing. So for now, we'll just poll like we did before when using RPi.GPIO
+ from gpiozero import Button
self.button = Button(gpio_input, bounce_time=0.008)
self.button.when_pressed = self._gpio_down
diff --git a/pistomp/hardware.py b/pistomp/hardware.py
index f288eb22..d15a8350 100755
--- a/pistomp/hardware.py
+++ b/pistomp/hardware.py
@@ -16,7 +16,6 @@
import logging
import os
from typing import Union
-import spidev
import sys
import common.token as Token
@@ -70,6 +69,7 @@ def toggle_tap_tempo_enable(self, bpm: float = 0.0):
logging.debug("tap tempo mode enabled: %f", bpm)
def init_spi(self):
+ import spidev
self.spi = spidev.SpiDev()
self.spi.open(0, 1) # Bus 0, CE1
# TODO SPI bus is shared by ADC and LCD. Ideally, they would use the same frequency.
diff --git a/pistomp/lcd320x240.py b/pistomp/lcd320x240.py
index a5b14bc2..8b5b4a20 100644
--- a/pistomp/lcd320x240.py
+++ b/pistomp/lcd320x240.py
@@ -13,8 +13,6 @@
# You should have received a copy of the GNU General Public License
# along with pi-stomp. If not, see .
-import board
-import digitalio
import logging
import os
import common.token as Token
@@ -33,20 +31,22 @@
class Lcd(abstract_lcd.Lcd):
- def __init__(self, cwd, handler=None, flip=False):
+ def __init__(self, cwd, handler=None, flip=False, display=None):
self.cwd = cwd
self.imagedir = os.path.join(cwd, "images")
Config(os.path.join(cwd, 'ui', 'config.json'))
self.handler = handler
self.flip = flip
- # TODO would be good to decouple the actual LCD hardware. This file should work for any 320x240 display
- display = LcdIli9341(board.SPI(),
- digitalio.DigitalInOut(board.CE0),
- digitalio.DigitalInOut(board.D6),
- digitalio.DigitalInOut(board.D5),
- 24000000,
- flip)
+ if display is None:
+ import board
+ import digitalio
+ display = LcdIli9341(board.SPI(),
+ digitalio.DigitalInOut(board.CE0),
+ digitalio.DigitalInOut(board.D6),
+ digitalio.DigitalInOut(board.D5),
+ 24000000,
+ flip)
# Colors
self.background = (0, 0, 0)
@@ -623,9 +623,11 @@ def splash_show(self, boot=True):
self.splash_panel.refresh()
def cleanup(self):
- self.pstack.pop_panel(None) # current panel
- self.pstack.pop_panel(self.footswitch_panel)
- if self.main_panel_pushed:
+ if self.pstack.current is not None:
+ self.pstack.pop_panel(None)
+ if self.footswitch_panel in self.pstack.stack:
+ self.pstack.pop_panel(self.footswitch_panel)
+ if self.main_panel_pushed and self.main_panel in self.pstack.stack:
self.pstack.pop_panel(self.main_panel)
if self.w_splash is not None:
self.w_splash.set_foreground(self.color_splash_down)
diff --git a/pistomp/lcdgfx.py b/pistomp/lcdgfx.py
index 0c9428c0..a82acb55 100755
--- a/pistomp/lcdgfx.py
+++ b/pistomp/lcdgfx.py
@@ -18,7 +18,7 @@
import os
import pistomp.lcd as abstract_lcd
-from gfxhat import touch, lcd, backlight, fonts
+from typing import Any
from PIL import Image, ImageFont, ImageDraw
from pistomp.footswitch import Footswitch # TODO would like to avoid this module knowing such details
@@ -27,13 +27,20 @@
class Lcd(abstract_lcd.Lcd):
__single = None
- def __init__(self, cwd):
+ def __init__(self, cwd, lcd=None, backlight=None, touch=None):
if Lcd.__single:
raise Lcd.__single
Lcd.__single = self
- # GFX properties
- self.width, self.height = lcd.dimensions()
+ self._lcd: Any = lcd
+ self._backlight: Any = backlight
+ self._touch: Any = touch
+
+ if lcd is None:
+ from gfxhat import touch, lcd, backlight # type: ignore[import-untyped]
+ self._lcd, self._backlight, self._touch = lcd, backlight, touch
+
+ self.width, self.height = self._lcd.dimensions()
self.height -= 1 # TODO figure out why this is needed
self.num_leds = 6
@@ -103,6 +110,9 @@ def __init__(self, cwd):
self.supports_toolbar = False
+ def poll_updates(self):
+ pass # lcdgfx pushes eagerly on every refresh call
+
def clear_select(self):
pass
@@ -125,8 +135,8 @@ def splash_show(self, boot=True):
for x in range(0, self.width):
for y in range(0, self.height):
pixel = self.splash.getpixel((x, y))
- lcd.set_pixel(self.width - x - 1, self.height - y, pixel)
- lcd.show()
+ self._lcd.set_pixel(self.width - x - 1, self.height - y, pixel)
+ self._lcd.show()
def erase_zone(self, zone_idx):
self.images[zone_idx].paste(0, (0, 0, self.width, self.zone_height[zone_idx]))
@@ -145,8 +155,8 @@ def refresh_zone(self, zone_idx):
for x in range(0, self.width):
for y in range(0, self.zone_height[zone_idx]):
pixel = flipped.getpixel((x, y))
- lcd.set_pixel(self.width - x - 1, self.height - y - y_offset, pixel)
- lcd.show()
+ self._lcd.set_pixel(self.width - x - 1, self.height - y - y_offset, pixel)
+ self._lcd.show()
def refresh_menu(self, highlight_range=None, scroll_offset=0):
# Set Pixels
@@ -158,8 +168,8 @@ def refresh_menu(self, highlight_range=None, scroll_offset=0):
pixel = self.menu_image.getpixel((x, y_draw))
if highlight_range and (y_draw >= highlight_range[0]) and (y_draw <= highlight_range[1]): # TODO LAME
pixel = not pixel
- lcd.set_pixel(self.width - x - 1, self.height - y - y_offset, pixel)
- lcd.show()
+ self._lcd.set_pixel(self.width - x - 1, self.height - y - y_offset, pixel)
+ self._lcd.show()
def refresh_plugins(self):
self.refresh_zone(2)
@@ -171,24 +181,24 @@ def refresh_plugins(self):
def enable_backlight(self):
for x in range(6):
- backlight.set_pixel(x, 50, 100, 100)
- backlight.show()
+ self._backlight.set_pixel(x, 50, 100, 100)
+ self._backlight.show()
def cleanup(self):
- backlight.set_all(0, 0, 0)
- backlight.show()
- lcd.clear()
- lcd.show()
+ self._backlight.set_all(0, 0, 0)
+ self._backlight.show()
+ self._lcd.clear()
+ self._lcd.show()
for i in range(0, self.num_leds):
- touch.set_led(i, 0)
+ self._touch.set_led(i, 0)
def clear(self):
for x in range(6):
- backlight.set_pixel(x, 0, 0, 0)
- touch.set_led(x, 0)
- backlight.show()
- lcd.clear()
- lcd.show()
+ self._backlight.set_pixel(x, 0, 0, 0)
+ self._touch.set_led(x, 0)
+ self._backlight.show()
+ self._lcd.clear()
+ self._lcd.show()
def erase_all(self):
for z in range(self.zones):
@@ -350,7 +360,7 @@ def draw_plugin_select(self, plugin=None):
self.erase_zone(4)
self.erase_zone(6)
- if plugin is not None:
+ if plugin is not None and plugin.lcd_xyz is not None:
x = plugin.lcd_xyz[0]
y = plugin.lcd_xyz[1]
zone = plugin.lcd_xyz[2] - 1
@@ -463,8 +473,8 @@ def draw_plugins(self, plugins):
if x > xwrap:
zone += 2
x = 0
- if y >= ymax:
- break # Only display 2 rows, huge pedalboards won't fully render # TODO make sure this works
+ if zone > 5:
+ break # Only display 2 rows, huge pedalboards won't fully render
self.refresh_plugins()
def shorten_name(self, name, width):
diff --git a/pistomp/ledstrip.py b/pistomp/ledstrip.py
index 96bd64e0..8bbbb79f 100644
--- a/pistomp/ledstrip.py
+++ b/pistomp/ledstrip.py
@@ -13,24 +13,23 @@
# You should have received a copy of the GNU General Public License
# along with pi-stomp. If not, see .
-import matplotlib
+import logging
from PIL import ImageColor
import common.util as Util
import pistomp.category as Category
-import board
-import neopixel
# LED strip configuration: # TODO get these from hardware impl (pisompcore.py)
LED_COUNT = 6 # Number of LED pixels.
-LED_PIN = board.D13 # GPIO pin connected to the pixels (must have PWM).
LED_BRIGHTNESS = 0.19 # Set to 0 for darkest, 1.0 for brightest (0.19 seems good, 0.06 for photos)
class Ledstrip:
def __init__(self):
- # Create NeoPixel object with appropriate configuration
- self.strip = neopixel.NeoPixel(LED_PIN, LED_COUNT, brightness=LED_BRIGHTNESS) # GPIO18 (PWM-capable), 8 pixels
+ import board
+ import neopixel
+ self._led_pin = board.D13 # TODO get this from hardware impl (pisompcore.py)
+ self.strip = neopixel.NeoPixel(self._led_pin, LED_COUNT, brightness=LED_BRIGHTNESS)
self.pixels = []
def add_pixel(self, id, position):
@@ -41,7 +40,7 @@ def add_pixel(self, id, position):
return p
def get_gpio(self):
- return LED_PIN
+ return self._led_pin
def cleanup(self):
for p in self.pixels:
@@ -70,6 +69,7 @@ def set_enable(self, enable):
# set the color for the pixel based on the name or rgb
def set_color(self, color):
+ import matplotlib
try:
c = Util.DICT_GET(self.color_cache, color)
if c is None:
diff --git a/pistomp/pistomp.py b/pistomp/pistomp.py
index e697e11d..970e4299 100755
--- a/pistomp/pistomp.py
+++ b/pistomp/pistomp.py
@@ -21,8 +21,6 @@
#
# A new version with different controls should have a new separate subclass
-import gpiozero as GPIO
-
from pathlib import Path
import common.token as Token
import common.util as Util
@@ -33,7 +31,6 @@
import pistomp.hardware as hardware
import pistomp.relay as Relay
-import pistomp.lcdgfx as Lcd
import sys
import time
@@ -95,6 +92,7 @@ def __init__(self, cfg, mod, midiout, refresh_callback):
self.init_encoders()
def init_lcd(self):
+ import pistomp.lcdgfx as Lcd
self.mod.add_lcd(Lcd.Lcd(self.mod.homedir))
def init_analog_controls(self):
diff --git a/pistomp/relay.py b/pistomp/relay.py
index d098b25d..5099e44c 100755
--- a/pistomp/relay.py
+++ b/pistomp/relay.py
@@ -16,7 +16,6 @@
import logging
import os
from pathlib import Path
-import gpiozero as GPIO
import shutil
import time
@@ -24,6 +23,7 @@
class Relay:
def __init__(self, set_pin, reset_pin):
+ import gpiozero as GPIO
self.enabled = False
self.set_pin = set_pin
self.reset_pin = reset_pin
diff --git a/pistomp/settings.py b/pistomp/settings.py
index 6c832feb..7190398a 100644
--- a/pistomp/settings.py
+++ b/pistomp/settings.py
@@ -55,4 +55,7 @@ def set_setting(self, name, value):
# Each set results in a file dump
with open(self.file, 'w') as ymlfile:
yaml.dump(self.data, ymlfile)
+ try:
shutil.chown(self.file, user=USER, group=USER)
+ except LookupError:
+ pass
diff --git a/pyproject.toml b/pyproject.toml
index a870238f..6bf66bd4 100644
--- a/pyproject.toml
+++ b/pyproject.toml
@@ -22,6 +22,7 @@ dependencies = [
"python-rtmidi>=1.4",
"requests>=2.28",
"Pillow>=9.4",
+ "numpy>=1.24",
"pyalsaaudio>=0.9; sys_platform == 'linux'",
"websockets>=15.0.1",
"gpiozero>=2.0; sys_platform == 'linux'",
@@ -42,6 +43,20 @@ hardware = [
"spidev>=3.0; sys_platform == 'linux'",
]
+# Local emulator (macOS / Linux desktop, no Pi hardware required)
+emulator = ["pygame>=2.5"]
+
+[dependency-groups]
+dev = [
+ "numpy>=2.4.1",
+ "pillow>=12.0.0",
+ "pyright>=1.1.408",
+ "pytest>=9.0.2",
+ "pytest-cov>=7.0.0",
+ "ruff>=0.1.0",
+ "typing-extensions>=4.15.0",
+]
+
[project.urls]
Homepage = "https://treefallsound.com"
Documentation = "https://www.treefallsound.com/wiki/doku.php"
@@ -74,14 +89,3 @@ venvPath = "."
venv = ".venv"
stubPath = "typings"
pythonVersion = "3.11"
-
-[dependency-groups]
-dev = [
- "numpy>=2.4.1",
- "pillow>=12.0.0",
- "pyright>=1.1.408",
- "pytest>=9.0.2",
- "pytest-cov>=7.0.0",
- "ruff>=0.1.0",
- "typing-extensions>=4.15.0",
-]
diff --git a/run_emulator.sh b/run_emulator.sh
new file mode 100755
index 00000000..13ddd21c
--- /dev/null
+++ b/run_emulator.sh
@@ -0,0 +1,56 @@
+#!/usr/bin/env sh
+# Launch the pi-stomp emulator, wiring in lilv if available outside the venv.
+
+# Locate the lilv Python binding and shared library.
+# Priority: pkg-config (Linux/macOS system install), then Homebrew.
+_lilv_pypath=""
+_lilv_libpath=""
+
+if command -v pkg-config >/dev/null 2>&1 && pkg-config --exists lilv-0 2>/dev/null; then
+ _libdir=$(pkg-config --variable=libdir lilv-0)
+ if [ -n "$_libdir" ]; then
+ _lilv_libpath="$_libdir"
+ # Look for a Python binding in the same prefix
+ _prefix=$(pkg-config --variable=prefix lilv-0)
+ for _pydir in "$_prefix"/lib/python*/site-packages; do
+ if [ -f "$_pydir/lilv.py" ]; then
+ _lilv_pypath="$_pydir"
+ break
+ fi
+ done
+ fi
+fi
+
+# Homebrew fallback (macOS)
+if [ -z "$_lilv_pypath" ] && command -v brew >/dev/null 2>&1; then
+ _brew_prefix=$(brew --prefix lilv 2>/dev/null)
+ if [ -n "$_brew_prefix" ]; then
+ _lilv_libpath="$_brew_prefix/lib"
+ for _pydir in "$_brew_prefix"/lib/python*/site-packages; do
+ if [ -f "$_pydir/lilv.py" ]; then
+ _lilv_pypath="$_pydir"
+ break
+ fi
+ done
+ fi
+fi
+
+if [ -n "$_lilv_pypath" ]; then
+ export PYTHONPATH="${_lilv_pypath}${PYTHONPATH:+:$PYTHONPATH}"
+fi
+if [ -n "$_lilv_libpath" ]; then
+ # macOS uses DYLD_LIBRARY_PATH; Linux uses LD_LIBRARY_PATH
+ case "$(uname -s)" in
+ Darwin) export DYLD_LIBRARY_PATH="${_lilv_libpath}${DYLD_LIBRARY_PATH:+:$DYLD_LIBRARY_PATH}" ;;
+ *) export LD_LIBRARY_PATH="${_lilv_libpath}${LD_LIBRARY_PATH:+:$LD_LIBRARY_PATH}" ;;
+ esac
+fi
+
+# Optional first argument: v1 / v2 / v3 (default: v3)
+_version="${1:-v3}"
+case "$_version" in
+ v1|v2|v3) shift ;;
+ *) _version="v3" ;;
+esac
+
+exec uv run python3 modalapistomp.py --host "emulator_${_version}" "$@"
diff --git a/uilib/lcd_ili9341.py b/uilib/lcd_ili9341.py
index 2a8b386f..ccc04fa7 100644
--- a/uilib/lcd_ili9341.py
+++ b/uilib/lcd_ili9341.py
@@ -13,8 +13,6 @@
# You should have received a copy of the GNU General Public License
# along with pi-stomp. If not, see .
-import adafruit_rgb_display.ili9341 as ili9341
-
from uilib.panel import LcdBase, Box
from functools import cached_property
import logging
@@ -28,8 +26,8 @@ class LcdIli9341(LcdBase):
# XXX
# TODO: Turn "flip" into all 90deg angle combinations
def __init__(self, spi, cs_pin, dc_pin, reset_pin, baudrate, flip=True):
+ import adafruit_rgb_display.ili9341 as ili9341
rst = reset_pin if not self.has_system_splash else None
-
self.disp = ili9341.ILI9341(spi, cs=cs_pin, dc=dc_pin, rst=rst, baudrate=baudrate)
# Use this to assure we don't have multiple threads trying to change the screen
diff --git a/uilib/panel.py b/uilib/panel.py
index ddad8bfc..224fd7a8 100644
--- a/uilib/panel.py
+++ b/uilib/panel.py
@@ -13,8 +13,8 @@
# You should have received a copy of the GNU General Public License
# along with pi-stomp. If not, see .
+from abc import ABC
from uilib.container import *
-from pathlib import Path
#
# Note about coordinates:
@@ -156,15 +156,19 @@ def _draw_outline(self, image, draw, real_box):
color = self.fgnd_color
draw.rounded_rectangle(real_box.PIL_rect, self.radius, None, color, self.outline)
-class LcdBase:
- def dimensions(self):
- pass
+class LcdBase(ABC):
+ def dimensions(self) -> tuple[int, int]:
+ ...
- def default_format(self):
- pass
+ def default_format(self) -> str:
+ ...
- def update(self, image, box = None):
- pass
+ def update(self, image, box = None) -> None:
+ ...
+
+ @property
+ def has_system_splash(self) -> bool:
+ return False
class PanelStack(ContainerWidget):
def __init__(self, lcd, box = None, image_format = None, use_dimming = True):
diff --git a/uv.lock b/uv.lock
index b100566a..3b93316e 100644
--- a/uv.lock
+++ b/uv.lock
@@ -851,6 +851,8 @@ source = { editable = "." }
dependencies = [
{ name = "gpiozero", marker = "sys_platform == 'linux'" },
{ name = "jsonschema" },
+ { name = "numpy" },
+ { name = "pillow" },
{ name = "pyalsaaudio", marker = "sys_platform == 'linux'" },
{ name = "python-rtmidi" },
{ name = "pyyaml" },
@@ -859,6 +861,9 @@ dependencies = [
]
[package.optional-dependencies]
+emulator = [
+ { name = "pygame" },
+]
hardware = [
{ name = "adafruit-circuitpython-mcp3xxx" },
{ name = "adafruit-circuitpython-neopixel" },
@@ -892,7 +897,10 @@ requires-dist = [
{ name = "jsonschema", specifier = ">=4.0" },
{ name = "lgpio", marker = "sys_platform == 'linux' and extra == 'hardware'", specifier = ">=0.2" },
{ name = "matplotlib", marker = "extra == 'hardware'", specifier = ">=3.5" },
+ { name = "numpy", specifier = ">=1.24" },
+ { name = "pillow", specifier = ">=9.4" },
{ name = "pyalsaaudio", marker = "sys_platform == 'linux'", specifier = ">=0.9" },
+ { name = "pygame", marker = "extra == 'emulator'", specifier = ">=2.5" },
{ name = "python-rtmidi", specifier = ">=1.4" },
{ name = "pyyaml", specifier = ">=6.0" },
{ name = "requests", specifier = ">=2.28" },
@@ -900,7 +908,7 @@ requires-dist = [
{ name = "rpi-ws281x", marker = "sys_platform == 'linux' and extra == 'hardware'", specifier = ">=5.0" },
{ name = "spidev", marker = "sys_platform == 'linux' and extra == 'hardware'", specifier = ">=3.0" },
]
-provides-extras = ["hardware"]
+provides-extras = ["hardware", "emulator"]
[package.metadata.requires-dev]
dev = [
@@ -918,89 +926,89 @@ dev = [
[[package]]
name = "pillow"
-version = "12.0.0"
+version = "12.2.0"
source = { registry = "https://pypi.org/simple" }
-sdist = { url = "https://files.pythonhosted.org/packages/5a/b0/cace85a1b0c9775a9f8f5d5423c8261c858760e2466c79b2dd184638b056/pillow-12.0.0.tar.gz", hash = "sha256:87d4f8125c9988bfbed67af47dd7a953e2fc7b0cc1e7800ec6d2080d490bb353", size = 47008828, upload-time = "2025-10-15T18:24:14.008Z" }
+sdist = { url = "https://files.pythonhosted.org/packages/8c/21/c2bcdd5906101a30244eaffc1b6e6ce71a31bd0742a01eb89e660ebfac2d/pillow-12.2.0.tar.gz", hash = "sha256:a830b1a40919539d07806aa58e1b114df53ddd43213d9c8b75847eee6c0182b5", size = 46987819, upload-time = "2026-04-01T14:46:17.687Z" }
wheels = [
- { url = "https://files.pythonhosted.org/packages/0e/5a/a2f6773b64edb921a756eb0729068acad9fc5208a53f4a349396e9436721/pillow-12.0.0-cp311-cp311-macosx_10_10_x86_64.whl", hash = "sha256:0fd00cac9c03256c8b2ff58f162ebcd2587ad3e1f2e397eab718c47e24d231cc", size = 5289798, upload-time = "2025-10-15T18:21:47.763Z" },
- { url = "https://files.pythonhosted.org/packages/2e/05/069b1f8a2e4b5a37493da6c5868531c3f77b85e716ad7a590ef87d58730d/pillow-12.0.0-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:a3475b96f5908b3b16c47533daaa87380c491357d197564e0ba34ae75c0f3257", size = 4650589, upload-time = "2025-10-15T18:21:49.515Z" },
- { url = "https://files.pythonhosted.org/packages/61/e3/2c820d6e9a36432503ead175ae294f96861b07600a7156154a086ba7111a/pillow-12.0.0-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:110486b79f2d112cf6add83b28b627e369219388f64ef2f960fef9ebaf54c642", size = 6230472, upload-time = "2025-10-15T18:21:51.052Z" },
- { url = "https://files.pythonhosted.org/packages/4f/89/63427f51c64209c5e23d4d52071c8d0f21024d3a8a487737caaf614a5795/pillow-12.0.0-cp311-cp311-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:5269cc1caeedb67e6f7269a42014f381f45e2e7cd42d834ede3c703a1d915fe3", size = 8033887, upload-time = "2025-10-15T18:21:52.604Z" },
- { url = "https://files.pythonhosted.org/packages/f6/1b/c9711318d4901093c15840f268ad649459cd81984c9ec9887756cca049a5/pillow-12.0.0-cp311-cp311-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:aa5129de4e174daccbc59d0a3b6d20eaf24417d59851c07ebb37aeb02947987c", size = 6343964, upload-time = "2025-10-15T18:21:54.619Z" },
- { url = "https://files.pythonhosted.org/packages/41/1e/db9470f2d030b4995083044cd8738cdd1bf773106819f6d8ba12597d5352/pillow-12.0.0-cp311-cp311-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:bee2a6db3a7242ea309aa7ee8e2780726fed67ff4e5b40169f2c940e7eb09227", size = 7034756, upload-time = "2025-10-15T18:21:56.151Z" },
- { url = "https://files.pythonhosted.org/packages/cc/b0/6177a8bdd5ee4ed87cba2de5a3cc1db55ffbbec6176784ce5bb75aa96798/pillow-12.0.0-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:90387104ee8400a7b4598253b4c406f8958f59fcf983a6cea2b50d59f7d63d0b", size = 6458075, upload-time = "2025-10-15T18:21:57.759Z" },
- { url = "https://files.pythonhosted.org/packages/bc/5e/61537aa6fa977922c6a03253a0e727e6e4a72381a80d63ad8eec350684f2/pillow-12.0.0-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:bc91a56697869546d1b8f0a3ff35224557ae7f881050e99f615e0119bf934b4e", size = 7125955, upload-time = "2025-10-15T18:21:59.372Z" },
- { url = "https://files.pythonhosted.org/packages/1f/3d/d5033539344ee3cbd9a4d69e12e63ca3a44a739eb2d4c8da350a3d38edd7/pillow-12.0.0-cp311-cp311-win32.whl", hash = "sha256:27f95b12453d165099c84f8a8bfdfd46b9e4bda9e0e4b65f0635430027f55739", size = 6298440, upload-time = "2025-10-15T18:22:00.982Z" },
- { url = "https://files.pythonhosted.org/packages/4d/42/aaca386de5cc8bd8a0254516957c1f265e3521c91515b16e286c662854c4/pillow-12.0.0-cp311-cp311-win_amd64.whl", hash = "sha256:b583dc9070312190192631373c6c8ed277254aa6e6084b74bdd0a6d3b221608e", size = 6999256, upload-time = "2025-10-15T18:22:02.617Z" },
- { url = "https://files.pythonhosted.org/packages/ba/f1/9197c9c2d5708b785f631a6dfbfa8eb3fb9672837cb92ae9af812c13b4ed/pillow-12.0.0-cp311-cp311-win_arm64.whl", hash = "sha256:759de84a33be3b178a64c8ba28ad5c135900359e85fb662bc6e403ad4407791d", size = 2436025, upload-time = "2025-10-15T18:22:04.598Z" },
- { url = "https://files.pythonhosted.org/packages/2c/90/4fcce2c22caf044e660a198d740e7fbc14395619e3cb1abad12192c0826c/pillow-12.0.0-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:53561a4ddc36facb432fae7a9d8afbfaf94795414f5cdc5fc52f28c1dca90371", size = 5249377, upload-time = "2025-10-15T18:22:05.993Z" },
- { url = "https://files.pythonhosted.org/packages/fd/e0/ed960067543d080691d47d6938ebccbf3976a931c9567ab2fbfab983a5dd/pillow-12.0.0-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:71db6b4c1653045dacc1585c1b0d184004f0d7e694c7b34ac165ca70c0838082", size = 4650343, upload-time = "2025-10-15T18:22:07.718Z" },
- { url = "https://files.pythonhosted.org/packages/e7/a1/f81fdeddcb99c044bf7d6faa47e12850f13cee0849537a7d27eeab5534d4/pillow-12.0.0-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:2fa5f0b6716fc88f11380b88b31fe591a06c6315e955c096c35715788b339e3f", size = 6232981, upload-time = "2025-10-15T18:22:09.287Z" },
- { url = "https://files.pythonhosted.org/packages/88/e1/9098d3ce341a8750b55b0e00c03f1630d6178f38ac191c81c97a3b047b44/pillow-12.0.0-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:82240051c6ca513c616f7f9da06e871f61bfd7805f566275841af15015b8f98d", size = 8041399, upload-time = "2025-10-15T18:22:10.872Z" },
- { url = "https://files.pythonhosted.org/packages/a7/62/a22e8d3b602ae8cc01446d0c57a54e982737f44b6f2e1e019a925143771d/pillow-12.0.0-cp312-cp312-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:55f818bd74fe2f11d4d7cbc65880a843c4075e0ac7226bc1a23261dbea531953", size = 6347740, upload-time = "2025-10-15T18:22:12.769Z" },
- { url = "https://files.pythonhosted.org/packages/4f/87/424511bdcd02c8d7acf9f65caa09f291a519b16bd83c3fb3374b3d4ae951/pillow-12.0.0-cp312-cp312-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:b87843e225e74576437fd5b6a4c2205d422754f84a06942cfaf1dc32243e45a8", size = 7040201, upload-time = "2025-10-15T18:22:14.813Z" },
- { url = "https://files.pythonhosted.org/packages/dc/4d/435c8ac688c54d11755aedfdd9f29c9eeddf68d150fe42d1d3dbd2365149/pillow-12.0.0-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:c607c90ba67533e1b2355b821fef6764d1dd2cbe26b8c1005ae84f7aea25ff79", size = 6462334, upload-time = "2025-10-15T18:22:16.375Z" },
- { url = "https://files.pythonhosted.org/packages/2b/f2/ad34167a8059a59b8ad10bc5c72d4d9b35acc6b7c0877af8ac885b5f2044/pillow-12.0.0-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:21f241bdd5080a15bc86d3466a9f6074a9c2c2b314100dd896ac81ee6db2f1ba", size = 7134162, upload-time = "2025-10-15T18:22:17.996Z" },
- { url = "https://files.pythonhosted.org/packages/0c/b1/a7391df6adacf0a5c2cf6ac1cf1fcc1369e7d439d28f637a847f8803beb3/pillow-12.0.0-cp312-cp312-win32.whl", hash = "sha256:dd333073e0cacdc3089525c7df7d39b211bcdf31fc2824e49d01c6b6187b07d0", size = 6298769, upload-time = "2025-10-15T18:22:19.923Z" },
- { url = "https://files.pythonhosted.org/packages/a2/0b/d87733741526541c909bbf159e338dcace4f982daac6e5a8d6be225ca32d/pillow-12.0.0-cp312-cp312-win_amd64.whl", hash = "sha256:9fe611163f6303d1619bbcb653540a4d60f9e55e622d60a3108be0d5b441017a", size = 7001107, upload-time = "2025-10-15T18:22:21.644Z" },
- { url = "https://files.pythonhosted.org/packages/bc/96/aaa61ce33cc98421fb6088af2a03be4157b1e7e0e87087c888e2370a7f45/pillow-12.0.0-cp312-cp312-win_arm64.whl", hash = "sha256:7dfb439562f234f7d57b1ac6bc8fe7f838a4bd49c79230e0f6a1da93e82f1fad", size = 2436012, upload-time = "2025-10-15T18:22:23.621Z" },
- { url = "https://files.pythonhosted.org/packages/62/f2/de993bb2d21b33a98d031ecf6a978e4b61da207bef02f7b43093774c480d/pillow-12.0.0-cp313-cp313-ios_13_0_arm64_iphoneos.whl", hash = "sha256:0869154a2d0546545cde61d1789a6524319fc1897d9ee31218eae7a60ccc5643", size = 4045493, upload-time = "2025-10-15T18:22:25.758Z" },
- { url = "https://files.pythonhosted.org/packages/0e/b6/bc8d0c4c9f6f111a783d045310945deb769b806d7574764234ffd50bc5ea/pillow-12.0.0-cp313-cp313-ios_13_0_arm64_iphonesimulator.whl", hash = "sha256:a7921c5a6d31b3d756ec980f2f47c0cfdbce0fc48c22a39347a895f41f4a6ea4", size = 4120461, upload-time = "2025-10-15T18:22:27.286Z" },
- { url = "https://files.pythonhosted.org/packages/5d/57/d60d343709366a353dc56adb4ee1e7d8a2cc34e3fbc22905f4167cfec119/pillow-12.0.0-cp313-cp313-ios_13_0_x86_64_iphonesimulator.whl", hash = "sha256:1ee80a59f6ce048ae13cda1abf7fbd2a34ab9ee7d401c46be3ca685d1999a399", size = 3576912, upload-time = "2025-10-15T18:22:28.751Z" },
- { url = "https://files.pythonhosted.org/packages/a4/a4/a0a31467e3f83b94d37568294b01d22b43ae3c5d85f2811769b9c66389dd/pillow-12.0.0-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:c50f36a62a22d350c96e49ad02d0da41dbd17ddc2e29750dbdba4323f85eb4a5", size = 5249132, upload-time = "2025-10-15T18:22:30.641Z" },
- { url = "https://files.pythonhosted.org/packages/83/06/48eab21dd561de2914242711434c0c0eb992ed08ff3f6107a5f44527f5e9/pillow-12.0.0-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:5193fde9a5f23c331ea26d0cf171fbf67e3f247585f50c08b3e205c7aeb4589b", size = 4650099, upload-time = "2025-10-15T18:22:32.73Z" },
- { url = "https://files.pythonhosted.org/packages/fc/bd/69ed99fd46a8dba7c1887156d3572fe4484e3f031405fcc5a92e31c04035/pillow-12.0.0-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:bde737cff1a975b70652b62d626f7785e0480918dece11e8fef3c0cf057351c3", size = 6230808, upload-time = "2025-10-15T18:22:34.337Z" },
- { url = "https://files.pythonhosted.org/packages/ea/94/8fad659bcdbf86ed70099cb60ae40be6acca434bbc8c4c0d4ef356d7e0de/pillow-12.0.0-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:a6597ff2b61d121172f5844b53f21467f7082f5fb385a9a29c01414463f93b07", size = 8037804, upload-time = "2025-10-15T18:22:36.402Z" },
- { url = "https://files.pythonhosted.org/packages/20/39/c685d05c06deecfd4e2d1950e9a908aa2ca8bc4e6c3b12d93b9cafbd7837/pillow-12.0.0-cp313-cp313-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:0b817e7035ea7f6b942c13aa03bb554fc44fea70838ea21f8eb31c638326584e", size = 6345553, upload-time = "2025-10-15T18:22:38.066Z" },
- { url = "https://files.pythonhosted.org/packages/38/57/755dbd06530a27a5ed74f8cb0a7a44a21722ebf318edbe67ddbd7fb28f88/pillow-12.0.0-cp313-cp313-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:f4f1231b7dec408e8670264ce63e9c71409d9583dd21d32c163e25213ee2a344", size = 7037729, upload-time = "2025-10-15T18:22:39.769Z" },
- { url = "https://files.pythonhosted.org/packages/ca/b6/7e94f4c41d238615674d06ed677c14883103dce1c52e4af16f000338cfd7/pillow-12.0.0-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:6e51b71417049ad6ab14c49608b4a24d8fb3fe605e5dfabfe523b58064dc3d27", size = 6459789, upload-time = "2025-10-15T18:22:41.437Z" },
- { url = "https://files.pythonhosted.org/packages/9c/14/4448bb0b5e0f22dd865290536d20ec8a23b64e2d04280b89139f09a36bb6/pillow-12.0.0-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:d120c38a42c234dc9a8c5de7ceaaf899cf33561956acb4941653f8bdc657aa79", size = 7130917, upload-time = "2025-10-15T18:22:43.152Z" },
- { url = "https://files.pythonhosted.org/packages/dd/ca/16c6926cc1c015845745d5c16c9358e24282f1e588237a4c36d2b30f182f/pillow-12.0.0-cp313-cp313-win32.whl", hash = "sha256:4cc6b3b2efff105c6a1656cfe59da4fdde2cda9af1c5e0b58529b24525d0a098", size = 6302391, upload-time = "2025-10-15T18:22:44.753Z" },
- { url = "https://files.pythonhosted.org/packages/6d/2a/dd43dcfd6dae9b6a49ee28a8eedb98c7d5ff2de94a5d834565164667b97b/pillow-12.0.0-cp313-cp313-win_amd64.whl", hash = "sha256:4cf7fed4b4580601c4345ceb5d4cbf5a980d030fd5ad07c4d2ec589f95f09905", size = 7007477, upload-time = "2025-10-15T18:22:46.838Z" },
- { url = "https://files.pythonhosted.org/packages/77/f0/72ea067f4b5ae5ead653053212af05ce3705807906ba3f3e8f58ddf617e6/pillow-12.0.0-cp313-cp313-win_arm64.whl", hash = "sha256:9f0b04c6b8584c2c193babcccc908b38ed29524b29dd464bc8801bf10d746a3a", size = 2435918, upload-time = "2025-10-15T18:22:48.399Z" },
- { url = "https://files.pythonhosted.org/packages/f5/5e/9046b423735c21f0487ea6cb5b10f89ea8f8dfbe32576fe052b5ba9d4e5b/pillow-12.0.0-cp313-cp313t-macosx_10_13_x86_64.whl", hash = "sha256:7fa22993bac7b77b78cae22bad1e2a987ddf0d9015c63358032f84a53f23cdc3", size = 5251406, upload-time = "2025-10-15T18:22:49.905Z" },
- { url = "https://files.pythonhosted.org/packages/12/66/982ceebcdb13c97270ef7a56c3969635b4ee7cd45227fa707c94719229c5/pillow-12.0.0-cp313-cp313t-macosx_11_0_arm64.whl", hash = "sha256:f135c702ac42262573fe9714dfe99c944b4ba307af5eb507abef1667e2cbbced", size = 4653218, upload-time = "2025-10-15T18:22:51.587Z" },
- { url = "https://files.pythonhosted.org/packages/16/b3/81e625524688c31859450119bf12674619429cab3119eec0e30a7a1029cb/pillow-12.0.0-cp313-cp313t-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:c85de1136429c524e55cfa4e033b4a7940ac5c8ee4d9401cc2d1bf48154bbc7b", size = 6266564, upload-time = "2025-10-15T18:22:53.215Z" },
- { url = "https://files.pythonhosted.org/packages/98/59/dfb38f2a41240d2408096e1a76c671d0a105a4a8471b1871c6902719450c/pillow-12.0.0-cp313-cp313t-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:38df9b4bfd3db902c9c2bd369bcacaf9d935b2fff73709429d95cc41554f7b3d", size = 8069260, upload-time = "2025-10-15T18:22:54.933Z" },
- { url = "https://files.pythonhosted.org/packages/dc/3d/378dbea5cd1874b94c312425ca77b0f47776c78e0df2df751b820c8c1d6c/pillow-12.0.0-cp313-cp313t-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:7d87ef5795da03d742bf49439f9ca4d027cde49c82c5371ba52464aee266699a", size = 6379248, upload-time = "2025-10-15T18:22:56.605Z" },
- { url = "https://files.pythonhosted.org/packages/84/b0/d525ef47d71590f1621510327acec75ae58c721dc071b17d8d652ca494d8/pillow-12.0.0-cp313-cp313t-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:aff9e4d82d082ff9513bdd6acd4f5bd359f5b2c870907d2b0a9c5e10d40c88fe", size = 7066043, upload-time = "2025-10-15T18:22:58.53Z" },
- { url = "https://files.pythonhosted.org/packages/61/2c/aced60e9cf9d0cde341d54bf7932c9ffc33ddb4a1595798b3a5150c7ec4e/pillow-12.0.0-cp313-cp313t-musllinux_1_2_aarch64.whl", hash = "sha256:8d8ca2b210ada074d57fcee40c30446c9562e542fc46aedc19baf758a93532ee", size = 6490915, upload-time = "2025-10-15T18:23:00.582Z" },
- { url = "https://files.pythonhosted.org/packages/ef/26/69dcb9b91f4e59f8f34b2332a4a0a951b44f547c4ed39d3e4dcfcff48f89/pillow-12.0.0-cp313-cp313t-musllinux_1_2_x86_64.whl", hash = "sha256:99a7f72fb6249302aa62245680754862a44179b545ded638cf1fef59befb57ef", size = 7157998, upload-time = "2025-10-15T18:23:02.627Z" },
- { url = "https://files.pythonhosted.org/packages/61/2b/726235842220ca95fa441ddf55dd2382b52ab5b8d9c0596fe6b3f23dafe8/pillow-12.0.0-cp313-cp313t-win32.whl", hash = "sha256:4078242472387600b2ce8d93ade8899c12bf33fa89e55ec89fe126e9d6d5d9e9", size = 6306201, upload-time = "2025-10-15T18:23:04.709Z" },
- { url = "https://files.pythonhosted.org/packages/c0/3d/2afaf4e840b2df71344ababf2f8edd75a705ce500e5dc1e7227808312ae1/pillow-12.0.0-cp313-cp313t-win_amd64.whl", hash = "sha256:2c54c1a783d6d60595d3514f0efe9b37c8808746a66920315bfd34a938d7994b", size = 7013165, upload-time = "2025-10-15T18:23:06.46Z" },
- { url = "https://files.pythonhosted.org/packages/6f/75/3fa09aa5cf6ed04bee3fa575798ddf1ce0bace8edb47249c798077a81f7f/pillow-12.0.0-cp313-cp313t-win_arm64.whl", hash = "sha256:26d9f7d2b604cd23aba3e9faf795787456ac25634d82cd060556998e39c6fa47", size = 2437834, upload-time = "2025-10-15T18:23:08.194Z" },
- { url = "https://files.pythonhosted.org/packages/54/2a/9a8c6ba2c2c07b71bec92cf63e03370ca5e5f5c5b119b742bcc0cde3f9c5/pillow-12.0.0-cp314-cp314-ios_13_0_arm64_iphoneos.whl", hash = "sha256:beeae3f27f62308f1ddbcfb0690bf44b10732f2ef43758f169d5e9303165d3f9", size = 4045531, upload-time = "2025-10-15T18:23:10.121Z" },
- { url = "https://files.pythonhosted.org/packages/84/54/836fdbf1bfb3d66a59f0189ff0b9f5f666cee09c6188309300df04ad71fa/pillow-12.0.0-cp314-cp314-ios_13_0_arm64_iphonesimulator.whl", hash = "sha256:d4827615da15cd59784ce39d3388275ec093ae3ee8d7f0c089b76fa87af756c2", size = 4120554, upload-time = "2025-10-15T18:23:12.14Z" },
- { url = "https://files.pythonhosted.org/packages/0d/cd/16aec9f0da4793e98e6b54778a5fbce4f375c6646fe662e80600b8797379/pillow-12.0.0-cp314-cp314-ios_13_0_x86_64_iphonesimulator.whl", hash = "sha256:3e42edad50b6909089750e65c91aa09aaf1e0a71310d383f11321b27c224ed8a", size = 3576812, upload-time = "2025-10-15T18:23:13.962Z" },
- { url = "https://files.pythonhosted.org/packages/f6/b7/13957fda356dc46339298b351cae0d327704986337c3c69bb54628c88155/pillow-12.0.0-cp314-cp314-macosx_10_15_x86_64.whl", hash = "sha256:e5d8efac84c9afcb40914ab49ba063d94f5dbdf5066db4482c66a992f47a3a3b", size = 5252689, upload-time = "2025-10-15T18:23:15.562Z" },
- { url = "https://files.pythonhosted.org/packages/fc/f5/eae31a306341d8f331f43edb2e9122c7661b975433de5e447939ae61c5da/pillow-12.0.0-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:266cd5f2b63ff316d5a1bba46268e603c9caf5606d44f38c2873c380950576ad", size = 4650186, upload-time = "2025-10-15T18:23:17.379Z" },
- { url = "https://files.pythonhosted.org/packages/86/62/2a88339aa40c4c77e79108facbd307d6091e2c0eb5b8d3cf4977cfca2fe6/pillow-12.0.0-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:58eea5ebe51504057dd95c5b77d21700b77615ab0243d8152793dc00eb4faf01", size = 6230308, upload-time = "2025-10-15T18:23:18.971Z" },
- { url = "https://files.pythonhosted.org/packages/c7/33/5425a8992bcb32d1cb9fa3dd39a89e613d09a22f2c8083b7bf43c455f760/pillow-12.0.0-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:f13711b1a5ba512d647a0e4ba79280d3a9a045aaf7e0cc6fbe96b91d4cdf6b0c", size = 8039222, upload-time = "2025-10-15T18:23:20.909Z" },
- { url = "https://files.pythonhosted.org/packages/d8/61/3f5d3b35c5728f37953d3eec5b5f3e77111949523bd2dd7f31a851e50690/pillow-12.0.0-cp314-cp314-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:6846bd2d116ff42cba6b646edf5bf61d37e5cbd256425fa089fee4ff5c07a99e", size = 6346657, upload-time = "2025-10-15T18:23:23.077Z" },
- { url = "https://files.pythonhosted.org/packages/3a/be/ee90a3d79271227e0f0a33c453531efd6ed14b2e708596ba5dd9be948da3/pillow-12.0.0-cp314-cp314-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:c98fa880d695de164b4135a52fd2e9cd7b7c90a9d8ac5e9e443a24a95ef9248e", size = 7038482, upload-time = "2025-10-15T18:23:25.005Z" },
- { url = "https://files.pythonhosted.org/packages/44/34/a16b6a4d1ad727de390e9bd9f19f5f669e079e5826ec0f329010ddea492f/pillow-12.0.0-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:fa3ed2a29a9e9d2d488b4da81dcb54720ac3104a20bf0bd273f1e4648aff5af9", size = 6461416, upload-time = "2025-10-15T18:23:27.009Z" },
- { url = "https://files.pythonhosted.org/packages/b6/39/1aa5850d2ade7d7ba9f54e4e4c17077244ff7a2d9e25998c38a29749eb3f/pillow-12.0.0-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:d034140032870024e6b9892c692fe2968493790dd57208b2c37e3fb35f6df3ab", size = 7131584, upload-time = "2025-10-15T18:23:29.752Z" },
- { url = "https://files.pythonhosted.org/packages/bf/db/4fae862f8fad0167073a7733973bfa955f47e2cac3dc3e3e6257d10fab4a/pillow-12.0.0-cp314-cp314-win32.whl", hash = "sha256:1b1b133e6e16105f524a8dec491e0586d072948ce15c9b914e41cdadd209052b", size = 6400621, upload-time = "2025-10-15T18:23:32.06Z" },
- { url = "https://files.pythonhosted.org/packages/2b/24/b350c31543fb0107ab2599464d7e28e6f856027aadda995022e695313d94/pillow-12.0.0-cp314-cp314-win_amd64.whl", hash = "sha256:8dc232e39d409036af549c86f24aed8273a40ffa459981146829a324e0848b4b", size = 7142916, upload-time = "2025-10-15T18:23:34.71Z" },
- { url = "https://files.pythonhosted.org/packages/0f/9b/0ba5a6fd9351793996ef7487c4fdbde8d3f5f75dbedc093bb598648fddf0/pillow-12.0.0-cp314-cp314-win_arm64.whl", hash = "sha256:d52610d51e265a51518692045e372a4c363056130d922a7351429ac9f27e70b0", size = 2523836, upload-time = "2025-10-15T18:23:36.967Z" },
- { url = "https://files.pythonhosted.org/packages/f5/7a/ceee0840aebc579af529b523d530840338ecf63992395842e54edc805987/pillow-12.0.0-cp314-cp314t-macosx_10_15_x86_64.whl", hash = "sha256:1979f4566bb96c1e50a62d9831e2ea2d1211761e5662afc545fa766f996632f6", size = 5255092, upload-time = "2025-10-15T18:23:38.573Z" },
- { url = "https://files.pythonhosted.org/packages/44/76/20776057b4bfd1aef4eeca992ebde0f53a4dce874f3ae693d0ec90a4f79b/pillow-12.0.0-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:b2e4b27a6e15b04832fe9bf292b94b5ca156016bbc1ea9c2c20098a0320d6cf6", size = 4653158, upload-time = "2025-10-15T18:23:40.238Z" },
- { url = "https://files.pythonhosted.org/packages/82/3f/d9ff92ace07be8836b4e7e87e6a4c7a8318d47c2f1463ffcf121fc57d9cb/pillow-12.0.0-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:fb3096c30df99fd01c7bf8e544f392103d0795b9f98ba71a8054bcbf56b255f1", size = 6267882, upload-time = "2025-10-15T18:23:42.434Z" },
- { url = "https://files.pythonhosted.org/packages/9f/7a/4f7ff87f00d3ad33ba21af78bfcd2f032107710baf8280e3722ceec28cda/pillow-12.0.0-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:7438839e9e053ef79f7112c881cef684013855016f928b168b81ed5835f3e75e", size = 8071001, upload-time = "2025-10-15T18:23:44.29Z" },
- { url = "https://files.pythonhosted.org/packages/75/87/fcea108944a52dad8cca0715ae6247e271eb80459364a98518f1e4f480c1/pillow-12.0.0-cp314-cp314t-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:5d5c411a8eaa2299322b647cd932586b1427367fd3184ffbb8f7a219ea2041ca", size = 6380146, upload-time = "2025-10-15T18:23:46.065Z" },
- { url = "https://files.pythonhosted.org/packages/91/52/0d31b5e571ef5fd111d2978b84603fce26aba1b6092f28e941cb46570745/pillow-12.0.0-cp314-cp314t-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:d7e091d464ac59d2c7ad8e7e08105eaf9dafbc3883fd7265ffccc2baad6ac925", size = 7067344, upload-time = "2025-10-15T18:23:47.898Z" },
- { url = "https://files.pythonhosted.org/packages/7b/f4/2dd3d721f875f928d48e83bb30a434dee75a2531bca839bb996bb0aa5a91/pillow-12.0.0-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:792a2c0be4dcc18af9d4a2dfd8a11a17d5e25274a1062b0ec1c2d79c76f3e7f8", size = 6491864, upload-time = "2025-10-15T18:23:49.607Z" },
- { url = "https://files.pythonhosted.org/packages/30/4b/667dfcf3d61fc309ba5a15b141845cece5915e39b99c1ceab0f34bf1d124/pillow-12.0.0-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:afbefa430092f71a9593a99ab6a4e7538bc9eabbf7bf94f91510d3503943edc4", size = 7158911, upload-time = "2025-10-15T18:23:51.351Z" },
- { url = "https://files.pythonhosted.org/packages/a2/2f/16cabcc6426c32218ace36bf0d55955e813f2958afddbf1d391849fee9d1/pillow-12.0.0-cp314-cp314t-win32.whl", hash = "sha256:3830c769decf88f1289680a59d4f4c46c72573446352e2befec9a8512104fa52", size = 6408045, upload-time = "2025-10-15T18:23:53.177Z" },
- { url = "https://files.pythonhosted.org/packages/35/73/e29aa0c9c666cf787628d3f0dcf379f4791fba79f4936d02f8b37165bdf8/pillow-12.0.0-cp314-cp314t-win_amd64.whl", hash = "sha256:905b0365b210c73afb0ebe9101a32572152dfd1c144c7e28968a331b9217b94a", size = 7148282, upload-time = "2025-10-15T18:23:55.316Z" },
- { url = "https://files.pythonhosted.org/packages/c1/70/6b41bdcddf541b437bbb9f47f94d2db5d9ddef6c37ccab8c9107743748a4/pillow-12.0.0-cp314-cp314t-win_arm64.whl", hash = "sha256:99353a06902c2e43b43e8ff74ee65a7d90307d82370604746738a1e0661ccca7", size = 2525630, upload-time = "2025-10-15T18:23:57.149Z" },
- { url = "https://files.pythonhosted.org/packages/1d/b3/582327e6c9f86d037b63beebe981425d6811104cb443e8193824ef1a2f27/pillow-12.0.0-pp311-pypy311_pp73-macosx_10_15_x86_64.whl", hash = "sha256:b22bd8c974942477156be55a768f7aa37c46904c175be4e158b6a86e3a6b7ca8", size = 5215068, upload-time = "2025-10-15T18:23:59.594Z" },
- { url = "https://files.pythonhosted.org/packages/fd/d6/67748211d119f3b6540baf90f92fae73ae51d5217b171b0e8b5f7e5d558f/pillow-12.0.0-pp311-pypy311_pp73-macosx_11_0_arm64.whl", hash = "sha256:805ebf596939e48dbb2e4922a1d3852cfc25c38160751ce02da93058b48d252a", size = 4614994, upload-time = "2025-10-15T18:24:01.669Z" },
- { url = "https://files.pythonhosted.org/packages/2d/e1/f8281e5d844c41872b273b9f2c34a4bf64ca08905668c8ae730eedc7c9fa/pillow-12.0.0-pp311-pypy311_pp73-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:cae81479f77420d217def5f54b5b9d279804d17e982e0f2fa19b1d1e14ab5197", size = 5246639, upload-time = "2025-10-15T18:24:03.403Z" },
- { url = "https://files.pythonhosted.org/packages/94/5a/0d8ab8ffe8a102ff5df60d0de5af309015163bf710c7bb3e8311dd3b3ad0/pillow-12.0.0-pp311-pypy311_pp73-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:aeaefa96c768fc66818730b952a862235d68825c178f1b3ffd4efd7ad2edcb7c", size = 6986839, upload-time = "2025-10-15T18:24:05.344Z" },
- { url = "https://files.pythonhosted.org/packages/20/2e/3434380e8110b76cd9eb00a363c484b050f949b4bbe84ba770bb8508a02c/pillow-12.0.0-pp311-pypy311_pp73-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:09f2d0abef9e4e2f349305a4f8cc784a8a6c2f58a8c4892eea13b10a943bd26e", size = 5313505, upload-time = "2025-10-15T18:24:07.137Z" },
- { url = "https://files.pythonhosted.org/packages/57/ca/5a9d38900d9d74785141d6580950fe705de68af735ff6e727cb911b64740/pillow-12.0.0-pp311-pypy311_pp73-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:bdee52571a343d721fb2eb3b090a82d959ff37fc631e3f70422e0c2e029f3e76", size = 5963654, upload-time = "2025-10-15T18:24:09.579Z" },
- { url = "https://files.pythonhosted.org/packages/95/7e/f896623c3c635a90537ac093c6a618ebe1a90d87206e42309cb5d98a1b9e/pillow-12.0.0-pp311-pypy311_pp73-win_amd64.whl", hash = "sha256:b290fd8aa38422444d4b50d579de197557f182ef1068b75f5aa8558638b8d0a5", size = 6997850, upload-time = "2025-10-15T18:24:11.495Z" },
+ { url = "https://files.pythonhosted.org/packages/68/e1/748f5663efe6edcfc4e74b2b93edfb9b8b99b67f21a854c3ae416500a2d9/pillow-12.2.0-cp311-cp311-macosx_10_10_x86_64.whl", hash = "sha256:8be29e59487a79f173507c30ddf57e733a357f67881430449bb32614075a40ab", size = 5354347, upload-time = "2026-04-01T14:42:44.255Z" },
+ { url = "https://files.pythonhosted.org/packages/47/a1/d5ff69e747374c33a3b53b9f98cca7889fce1fd03d79cdc4e1bccc6c5a87/pillow-12.2.0-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:71cde9a1e1551df7d34a25462fc60325e8a11a82cc2e2f54578e5e9a1e153d65", size = 4695873, upload-time = "2026-04-01T14:42:46.452Z" },
+ { url = "https://files.pythonhosted.org/packages/df/21/e3fbdf54408a973c7f7f89a23b2cb97a7ef30c61ab4142af31eee6aebc88/pillow-12.2.0-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:f490f9368b6fc026f021db16d7ec2fbf7d89e2edb42e8ec09d2c60505f5729c7", size = 6280168, upload-time = "2026-04-01T14:42:49.228Z" },
+ { url = "https://files.pythonhosted.org/packages/d3/f1/00b7278c7dd52b17ad4329153748f87b6756ec195ff786c2bdf12518337d/pillow-12.2.0-cp311-cp311-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:8bd7903a5f2a4545f6fd5935c90058b89d30045568985a71c79f5fd6edf9b91e", size = 8088188, upload-time = "2026-04-01T14:42:51.735Z" },
+ { url = "https://files.pythonhosted.org/packages/ad/cf/220a5994ef1b10e70e85748b75649d77d506499352be135a4989c957b701/pillow-12.2.0-cp311-cp311-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:3997232e10d2920a68d25191392e3a4487d8183039e1c74c2297f00ed1c50705", size = 6394401, upload-time = "2026-04-01T14:42:54.343Z" },
+ { url = "https://files.pythonhosted.org/packages/e9/bd/e51a61b1054f09437acfbc2ff9106c30d1eb76bc1453d428399946781253/pillow-12.2.0-cp311-cp311-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:e74473c875d78b8e9d5da2a70f7099549f9eb37ded4e2f6a463e60125bccd176", size = 7079655, upload-time = "2026-04-01T14:42:56.954Z" },
+ { url = "https://files.pythonhosted.org/packages/6b/3d/45132c57d5fb4b5744567c3817026480ac7fc3ce5d4c47902bc0e7f6f853/pillow-12.2.0-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:56a3f9c60a13133a98ecff6197af34d7824de9b7b38c3654861a725c970c197b", size = 6503105, upload-time = "2026-04-01T14:42:59.847Z" },
+ { url = "https://files.pythonhosted.org/packages/7d/2e/9df2fc1e82097b1df3dce58dc43286aa01068e918c07574711fcc53e6fb4/pillow-12.2.0-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:90e6f81de50ad6b534cab6e5aef77ff6e37722b2f5d908686f4a5c9eba17a909", size = 7203402, upload-time = "2026-04-01T14:43:02.664Z" },
+ { url = "https://files.pythonhosted.org/packages/bd/2e/2941e42858ebb67e50ae741473de81c2984e6eff7b397017623c676e2e8d/pillow-12.2.0-cp311-cp311-win32.whl", hash = "sha256:8c984051042858021a54926eb597d6ee3012393ce9c181814115df4c60b9a808", size = 6378149, upload-time = "2026-04-01T14:43:05.274Z" },
+ { url = "https://files.pythonhosted.org/packages/69/42/836b6f3cd7f3e5fa10a1f1a5420447c17966044c8fbf589cc0452d5502db/pillow-12.2.0-cp311-cp311-win_amd64.whl", hash = "sha256:6e6b2a0c538fc200b38ff9eb6628228b77908c319a005815f2dde585a0664b60", size = 7082626, upload-time = "2026-04-01T14:43:08.557Z" },
+ { url = "https://files.pythonhosted.org/packages/c2/88/549194b5d6f1f494b485e493edc6693c0a16f4ada488e5bd974ed1f42fad/pillow-12.2.0-cp311-cp311-win_arm64.whl", hash = "sha256:9a8a34cc89c67a65ea7437ce257cea81a9dad65b29805f3ecee8c8fe8ff25ffe", size = 2463531, upload-time = "2026-04-01T14:43:10.743Z" },
+ { url = "https://files.pythonhosted.org/packages/58/be/7482c8a5ebebbc6470b3eb791812fff7d5e0216c2be3827b30b8bb6603ed/pillow-12.2.0-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:2d192a155bbcec180f8564f693e6fd9bccff5a7af9b32e2e4bf8c9c69dbad6b5", size = 5308279, upload-time = "2026-04-01T14:43:13.246Z" },
+ { url = "https://files.pythonhosted.org/packages/d8/95/0a351b9289c2b5cbde0bacd4a83ebc44023e835490a727b2a3bd60ddc0f4/pillow-12.2.0-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:f3f40b3c5a968281fd507d519e444c35f0ff171237f4fdde090dd60699458421", size = 4695490, upload-time = "2026-04-01T14:43:15.584Z" },
+ { url = "https://files.pythonhosted.org/packages/de/af/4e8e6869cbed569d43c416fad3dc4ecb944cb5d9492defaed89ddd6fe871/pillow-12.2.0-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:03e7e372d5240cc23e9f07deca4d775c0817bffc641b01e9c3af208dbd300987", size = 6284462, upload-time = "2026-04-01T14:43:18.268Z" },
+ { url = "https://files.pythonhosted.org/packages/e9/9e/c05e19657fd57841e476be1ab46c4d501bffbadbafdc31a6d665f8b737b6/pillow-12.2.0-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:b86024e52a1b269467a802258c25521e6d742349d760728092e1bc2d135b4d76", size = 8094744, upload-time = "2026-04-01T14:43:20.716Z" },
+ { url = "https://files.pythonhosted.org/packages/2b/54/1789c455ed10176066b6e7e6da1b01e50e36f94ba584dc68d9eebfe9156d/pillow-12.2.0-cp312-cp312-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:7371b48c4fa448d20d2714c9a1f775a81155050d383333e0a6c15b1123dda005", size = 6398371, upload-time = "2026-04-01T14:43:23.443Z" },
+ { url = "https://files.pythonhosted.org/packages/43/e3/fdc657359e919462369869f1c9f0e973f353f9a9ee295a39b1fea8ee1a77/pillow-12.2.0-cp312-cp312-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:62f5409336adb0663b7caa0da5c7d9e7bdbaae9ce761d34669420c2a801b2780", size = 7087215, upload-time = "2026-04-01T14:43:26.758Z" },
+ { url = "https://files.pythonhosted.org/packages/8b/f8/2f6825e441d5b1959d2ca5adec984210f1ec086435b0ed5f52c19b3b8a6e/pillow-12.2.0-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:01afa7cf67f74f09523699b4e88c73fb55c13346d212a59a2db1f86b0a63e8c5", size = 6509783, upload-time = "2026-04-01T14:43:29.56Z" },
+ { url = "https://files.pythonhosted.org/packages/67/f9/029a27095ad20f854f9dba026b3ea6428548316e057e6fc3545409e86651/pillow-12.2.0-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:fc3d34d4a8fbec3e88a79b92e5465e0f9b842b628675850d860b8bd300b159f5", size = 7212112, upload-time = "2026-04-01T14:43:32.091Z" },
+ { url = "https://files.pythonhosted.org/packages/be/42/025cfe05d1be22dbfdb4f264fe9de1ccda83f66e4fc3aac94748e784af04/pillow-12.2.0-cp312-cp312-win32.whl", hash = "sha256:58f62cc0f00fd29e64b29f4fd923ffdb3859c9f9e6105bfc37ba1d08994e8940", size = 6378489, upload-time = "2026-04-01T14:43:34.601Z" },
+ { url = "https://files.pythonhosted.org/packages/5d/7b/25a221d2c761c6a8ae21bfa3874988ff2583e19cf8a27bf2fee358df7942/pillow-12.2.0-cp312-cp312-win_amd64.whl", hash = "sha256:7f84204dee22a783350679a0333981df803dac21a0190d706a50475e361c93f5", size = 7084129, upload-time = "2026-04-01T14:43:37.213Z" },
+ { url = "https://files.pythonhosted.org/packages/10/e1/542a474affab20fd4a0f1836cb234e8493519da6b76899e30bcc5d990b8b/pillow-12.2.0-cp312-cp312-win_arm64.whl", hash = "sha256:af73337013e0b3b46f175e79492d96845b16126ddf79c438d7ea7ff27783a414", size = 2463612, upload-time = "2026-04-01T14:43:39.421Z" },
+ { url = "https://files.pythonhosted.org/packages/4a/01/53d10cf0dbad820a8db274d259a37ba50b88b24768ddccec07355382d5ad/pillow-12.2.0-cp313-cp313-ios_13_0_arm64_iphoneos.whl", hash = "sha256:8297651f5b5679c19968abefd6bb84d95fe30ef712eb1b2d9b2d31ca61267f4c", size = 4100837, upload-time = "2026-04-01T14:43:41.506Z" },
+ { url = "https://files.pythonhosted.org/packages/0f/98/f3a6657ecb698c937f6c76ee564882945f29b79bad496abcba0e84659ec5/pillow-12.2.0-cp313-cp313-ios_13_0_arm64_iphonesimulator.whl", hash = "sha256:50d8520da2a6ce0af445fa6d648c4273c3eeefbc32d7ce049f22e8b5c3daecc2", size = 4176528, upload-time = "2026-04-01T14:43:43.773Z" },
+ { url = "https://files.pythonhosted.org/packages/69/bc/8986948f05e3ea490b8442ea1c1d4d990b24a7e43d8a51b2c7d8b1dced36/pillow-12.2.0-cp313-cp313-ios_13_0_x86_64_iphonesimulator.whl", hash = "sha256:766cef22385fa1091258ad7e6216792b156dc16d8d3fa607e7545b2b72061f1c", size = 3640401, upload-time = "2026-04-01T14:43:45.87Z" },
+ { url = "https://files.pythonhosted.org/packages/34/46/6c717baadcd62bc8ed51d238d521ab651eaa74838291bda1f86fe1f864c9/pillow-12.2.0-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:5d2fd0fa6b5d9d1de415060363433f28da8b1526c1c129020435e186794b3795", size = 5308094, upload-time = "2026-04-01T14:43:48.438Z" },
+ { url = "https://files.pythonhosted.org/packages/71/43/905a14a8b17fdb1ccb58d282454490662d2cb89a6bfec26af6d3520da5ec/pillow-12.2.0-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:56b25336f502b6ed02e889f4ece894a72612fe885889a6e8c4c80239ff6e5f5f", size = 4695402, upload-time = "2026-04-01T14:43:51.292Z" },
+ { url = "https://files.pythonhosted.org/packages/73/dd/42107efcb777b16fa0393317eac58f5b5cf30e8392e266e76e51cff28c3d/pillow-12.2.0-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:f1c943e96e85df3d3478f7b691f229887e143f81fedab9b20205349ab04d73ed", size = 6280005, upload-time = "2026-04-01T14:43:54.242Z" },
+ { url = "https://files.pythonhosted.org/packages/a8/68/b93e09e5e8549019e61acf49f65b1a8530765a7f812c77a7461bca7e4494/pillow-12.2.0-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:03f6fab9219220f041c74aeaa2939ff0062bd5c364ba9ce037197f4c6d498cd9", size = 8090669, upload-time = "2026-04-01T14:43:57.335Z" },
+ { url = "https://files.pythonhosted.org/packages/4b/6e/3ccb54ce8ec4ddd1accd2d89004308b7b0b21c4ac3d20fa70af4760a4330/pillow-12.2.0-cp313-cp313-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:5cdfebd752ec52bf5bb4e35d9c64b40826bc5b40a13df7c3cda20a2c03a0f5ed", size = 6395194, upload-time = "2026-04-01T14:43:59.864Z" },
+ { url = "https://files.pythonhosted.org/packages/67/ee/21d4e8536afd1a328f01b359b4d3997b291ffd35a237c877b331c1c3b71c/pillow-12.2.0-cp313-cp313-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:eedf4b74eda2b5a4b2b2fb4c006d6295df3bf29e459e198c90ea48e130dc75c3", size = 7082423, upload-time = "2026-04-01T14:44:02.74Z" },
+ { url = "https://files.pythonhosted.org/packages/78/5f/e9f86ab0146464e8c133fe85df987ed9e77e08b29d8d35f9f9f4d6f917ba/pillow-12.2.0-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:00a2865911330191c0b818c59103b58a5e697cae67042366970a6b6f1b20b7f9", size = 6505667, upload-time = "2026-04-01T14:44:05.381Z" },
+ { url = "https://files.pythonhosted.org/packages/ed/1e/409007f56a2fdce61584fd3acbc2bbc259857d555196cedcadc68c015c82/pillow-12.2.0-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:1e1757442ed87f4912397c6d35a0db6a7b52592156014706f17658ff58bbf795", size = 7208580, upload-time = "2026-04-01T14:44:08.39Z" },
+ { url = "https://files.pythonhosted.org/packages/23/c4/7349421080b12fb35414607b8871e9534546c128a11965fd4a7002ccfbee/pillow-12.2.0-cp313-cp313-win32.whl", hash = "sha256:144748b3af2d1b358d41286056d0003f47cb339b8c43a9ea42f5fea4d8c66b6e", size = 6375896, upload-time = "2026-04-01T14:44:11.197Z" },
+ { url = "https://files.pythonhosted.org/packages/3f/82/8a3739a5e470b3c6cbb1d21d315800d8e16bff503d1f16b03a4ec3212786/pillow-12.2.0-cp313-cp313-win_amd64.whl", hash = "sha256:390ede346628ccc626e5730107cde16c42d3836b89662a115a921f28440e6a3b", size = 7081266, upload-time = "2026-04-01T14:44:13.947Z" },
+ { url = "https://files.pythonhosted.org/packages/c3/25/f968f618a062574294592f668218f8af564830ccebdd1fa6200f598e65c5/pillow-12.2.0-cp313-cp313-win_arm64.whl", hash = "sha256:8023abc91fba39036dbce14a7d6535632f99c0b857807cbbbf21ecc9f4717f06", size = 2463508, upload-time = "2026-04-01T14:44:16.312Z" },
+ { url = "https://files.pythonhosted.org/packages/4d/a4/b342930964e3cb4dce5038ae34b0eab4653334995336cd486c5a8c25a00c/pillow-12.2.0-cp313-cp313t-macosx_10_13_x86_64.whl", hash = "sha256:042db20a421b9bafecc4b84a8b6e444686bd9d836c7fd24542db3e7df7baad9b", size = 5309927, upload-time = "2026-04-01T14:44:18.89Z" },
+ { url = "https://files.pythonhosted.org/packages/9f/de/23198e0a65a9cf06123f5435a5d95cea62a635697f8f03d134d3f3a96151/pillow-12.2.0-cp313-cp313t-macosx_11_0_arm64.whl", hash = "sha256:dd025009355c926a84a612fecf58bb315a3f6814b17ead51a8e48d3823d9087f", size = 4698624, upload-time = "2026-04-01T14:44:21.115Z" },
+ { url = "https://files.pythonhosted.org/packages/01/a6/1265e977f17d93ea37aa28aa81bad4fa597933879fac2520d24e021c8da3/pillow-12.2.0-cp313-cp313t-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:88ddbc66737e277852913bd1e07c150cc7bb124539f94c4e2df5344494e0a612", size = 6321252, upload-time = "2026-04-01T14:44:23.663Z" },
+ { url = "https://files.pythonhosted.org/packages/3c/83/5982eb4a285967baa70340320be9f88e57665a387e3a53a7f0db8231a0cd/pillow-12.2.0-cp313-cp313t-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:d362d1878f00c142b7e1a16e6e5e780f02be8195123f164edf7eddd911eefe7c", size = 8126550, upload-time = "2026-04-01T14:44:26.772Z" },
+ { url = "https://files.pythonhosted.org/packages/4e/48/6ffc514adce69f6050d0753b1a18fd920fce8cac87620d5a31231b04bfc5/pillow-12.2.0-cp313-cp313t-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:2c727a6d53cb0018aadd8018c2b938376af27914a68a492f59dfcaca650d5eea", size = 6433114, upload-time = "2026-04-01T14:44:29.615Z" },
+ { url = "https://files.pythonhosted.org/packages/36/a3/f9a77144231fb8d40ee27107b4463e205fa4677e2ca2548e14da5cf18dce/pillow-12.2.0-cp313-cp313t-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:efd8c21c98c5cc60653bcb311bef2ce0401642b7ce9d09e03a7da87c878289d4", size = 7115667, upload-time = "2026-04-01T14:44:32.773Z" },
+ { url = "https://files.pythonhosted.org/packages/c1/fc/ac4ee3041e7d5a565e1c4fd72a113f03b6394cc72ab7089d27608f8aaccb/pillow-12.2.0-cp313-cp313t-musllinux_1_2_aarch64.whl", hash = "sha256:9f08483a632889536b8139663db60f6724bfcb443c96f1b18855860d7d5c0fd4", size = 6538966, upload-time = "2026-04-01T14:44:35.252Z" },
+ { url = "https://files.pythonhosted.org/packages/c0/a8/27fb307055087f3668f6d0a8ccb636e7431d56ed0750e07a60547b1e083e/pillow-12.2.0-cp313-cp313t-musllinux_1_2_x86_64.whl", hash = "sha256:dac8d77255a37e81a2efcbd1fc05f1c15ee82200e6c240d7e127e25e365c39ea", size = 7238241, upload-time = "2026-04-01T14:44:37.875Z" },
+ { url = "https://files.pythonhosted.org/packages/ad/4b/926ab182c07fccae9fcb120043464e1ff1564775ec8864f21a0ebce6ac25/pillow-12.2.0-cp313-cp313t-win32.whl", hash = "sha256:ee3120ae9dff32f121610bb08e4313be87e03efeadfc6c0d18f89127e24d0c24", size = 6379592, upload-time = "2026-04-01T14:44:40.336Z" },
+ { url = "https://files.pythonhosted.org/packages/c2/c4/f9e476451a098181b30050cc4c9a3556b64c02cf6497ea421ac047e89e4b/pillow-12.2.0-cp313-cp313t-win_amd64.whl", hash = "sha256:325ca0528c6788d2a6c3d40e3568639398137346c3d6e66bb61db96b96511c98", size = 7085542, upload-time = "2026-04-01T14:44:43.251Z" },
+ { url = "https://files.pythonhosted.org/packages/00/a4/285f12aeacbe2d6dc36c407dfbbe9e96d4a80b0fb710a337f6d2ad978c75/pillow-12.2.0-cp313-cp313t-win_arm64.whl", hash = "sha256:2e5a76d03a6c6dcef67edabda7a52494afa4035021a79c8558e14af25313d453", size = 2465765, upload-time = "2026-04-01T14:44:45.996Z" },
+ { url = "https://files.pythonhosted.org/packages/bf/98/4595daa2365416a86cb0d495248a393dfc84e96d62ad080c8546256cb9c0/pillow-12.2.0-cp314-cp314-ios_13_0_arm64_iphoneos.whl", hash = "sha256:3adc9215e8be0448ed6e814966ecf3d9952f0ea40eb14e89a102b87f450660d8", size = 4100848, upload-time = "2026-04-01T14:44:48.48Z" },
+ { url = "https://files.pythonhosted.org/packages/0b/79/40184d464cf89f6663e18dfcf7ca21aae2491fff1a16127681bf1fa9b8cf/pillow-12.2.0-cp314-cp314-ios_13_0_arm64_iphonesimulator.whl", hash = "sha256:6a9adfc6d24b10f89588096364cc726174118c62130c817c2837c60cf08a392b", size = 4176515, upload-time = "2026-04-01T14:44:51.353Z" },
+ { url = "https://files.pythonhosted.org/packages/b0/63/703f86fd4c422a9cf722833670f4f71418fb116b2853ff7da722ea43f184/pillow-12.2.0-cp314-cp314-ios_13_0_x86_64_iphonesimulator.whl", hash = "sha256:6a6e67ea2e6feda684ed370f9a1c52e7a243631c025ba42149a2cc5934dec295", size = 3640159, upload-time = "2026-04-01T14:44:53.588Z" },
+ { url = "https://files.pythonhosted.org/packages/71/e0/fb22f797187d0be2270f83500aab851536101b254bfa1eae10795709d283/pillow-12.2.0-cp314-cp314-macosx_10_15_x86_64.whl", hash = "sha256:2bb4a8d594eacdfc59d9e5ad972aa8afdd48d584ffd5f13a937a664c3e7db0ed", size = 5312185, upload-time = "2026-04-01T14:44:56.039Z" },
+ { url = "https://files.pythonhosted.org/packages/ba/8c/1a9e46228571de18f8e28f16fabdfc20212a5d019f3e3303452b3f0a580d/pillow-12.2.0-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:80b2da48193b2f33ed0c32c38140f9d3186583ce7d516526d462645fd98660ae", size = 4695386, upload-time = "2026-04-01T14:44:58.663Z" },
+ { url = "https://files.pythonhosted.org/packages/70/62/98f6b7f0c88b9addd0e87c217ded307b36be024d4ff8869a812b241d1345/pillow-12.2.0-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:22db17c68434de69d8ecfc2fe821569195c0c373b25cccb9cbdacf2c6e53c601", size = 6280384, upload-time = "2026-04-01T14:45:01.5Z" },
+ { url = "https://files.pythonhosted.org/packages/5e/03/688747d2e91cfbe0e64f316cd2e8005698f76ada3130d0194664174fa5de/pillow-12.2.0-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:7b14cc0106cd9aecda615dd6903840a058b4700fcb817687d0ee4fc8b6e389be", size = 8091599, upload-time = "2026-04-01T14:45:04.5Z" },
+ { url = "https://files.pythonhosted.org/packages/f6/35/577e22b936fcdd66537329b33af0b4ccfefaeabd8aec04b266528cddb33c/pillow-12.2.0-cp314-cp314-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:8cbeb542b2ebc6fcdacabf8aca8c1a97c9b3ad3927d46b8723f9d4f033288a0f", size = 6396021, upload-time = "2026-04-01T14:45:07.117Z" },
+ { url = "https://files.pythonhosted.org/packages/11/8d/d2532ad2a603ca2b93ad9f5135732124e57811d0168155852f37fbce2458/pillow-12.2.0-cp314-cp314-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:4bfd07bc812fbd20395212969e41931001fd59eb55a60658b0e5710872e95286", size = 7083360, upload-time = "2026-04-01T14:45:09.763Z" },
+ { url = "https://files.pythonhosted.org/packages/5e/26/d325f9f56c7e039034897e7380e9cc202b1e368bfd04d4cbe6a441f02885/pillow-12.2.0-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:9aba9a17b623ef750a4d11b742cbafffeb48a869821252b30ee21b5e91392c50", size = 6507628, upload-time = "2026-04-01T14:45:12.378Z" },
+ { url = "https://files.pythonhosted.org/packages/5f/f7/769d5632ffb0988f1c5e7660b3e731e30f7f8ec4318e94d0a5d674eb65a4/pillow-12.2.0-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:deede7c263feb25dba4e82ea23058a235dcc2fe1f6021025dc71f2b618e26104", size = 7209321, upload-time = "2026-04-01T14:45:15.122Z" },
+ { url = "https://files.pythonhosted.org/packages/6a/7a/c253e3c645cd47f1aceea6a8bacdba9991bf45bb7dfe927f7c893e89c93c/pillow-12.2.0-cp314-cp314-win32.whl", hash = "sha256:632ff19b2778e43162304d50da0181ce24ac5bb8180122cbe1bf4673428328c7", size = 6479723, upload-time = "2026-04-01T14:45:17.797Z" },
+ { url = "https://files.pythonhosted.org/packages/cd/8b/601e6566b957ca50e28725cb6c355c59c2c8609751efbecd980db44e0349/pillow-12.2.0-cp314-cp314-win_amd64.whl", hash = "sha256:4e6c62e9d237e9b65fac06857d511e90d8461a32adcc1b9065ea0c0fa3a28150", size = 7217400, upload-time = "2026-04-01T14:45:20.529Z" },
+ { url = "https://files.pythonhosted.org/packages/d6/94/220e46c73065c3e2951bb91c11a1fb636c8c9ad427ac3ce7d7f3359b9b2f/pillow-12.2.0-cp314-cp314-win_arm64.whl", hash = "sha256:b1c1fbd8a5a1af3412a0810d060a78b5136ec0836c8a4ef9aa11807f2a22f4e1", size = 2554835, upload-time = "2026-04-01T14:45:23.162Z" },
+ { url = "https://files.pythonhosted.org/packages/b6/ab/1b426a3974cb0e7da5c29ccff4807871d48110933a57207b5a676cccc155/pillow-12.2.0-cp314-cp314t-macosx_10_15_x86_64.whl", hash = "sha256:57850958fe9c751670e49b2cecf6294acc99e562531f4bd317fa5ddee2068463", size = 5314225, upload-time = "2026-04-01T14:45:25.637Z" },
+ { url = "https://files.pythonhosted.org/packages/19/1e/dce46f371be2438eecfee2a1960ee2a243bbe5e961890146d2dee1ff0f12/pillow-12.2.0-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:d5d38f1411c0ed9f97bcb49b7bd59b6b7c314e0e27420e34d99d844b9ce3b6f3", size = 4698541, upload-time = "2026-04-01T14:45:28.355Z" },
+ { url = "https://files.pythonhosted.org/packages/55/c3/7fbecf70adb3a0c33b77a300dc52e424dc22ad8cdc06557a2e49523b703d/pillow-12.2.0-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:5c0a9f29ca8e79f09de89293f82fc9b0270bb4af1d58bc98f540cc4aedf03166", size = 6322251, upload-time = "2026-04-01T14:45:30.924Z" },
+ { url = "https://files.pythonhosted.org/packages/1c/3c/7fbc17cfb7e4fe0ef1642e0abc17fc6c94c9f7a16be41498e12e2ba60408/pillow-12.2.0-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:1610dd6c61621ae1cf811bef44d77e149ce3f7b95afe66a4512f8c59f25d9ebe", size = 8127807, upload-time = "2026-04-01T14:45:33.908Z" },
+ { url = "https://files.pythonhosted.org/packages/ff/c3/a8ae14d6defd2e448493ff512fae903b1e9bd40b72efb6ec55ce0048c8ce/pillow-12.2.0-cp314-cp314t-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:0a34329707af4f73cf1782a36cd2289c0368880654a2c11f027bcee9052d35dd", size = 6433935, upload-time = "2026-04-01T14:45:36.623Z" },
+ { url = "https://files.pythonhosted.org/packages/6e/32/2880fb3a074847ac159d8f902cb43278a61e85f681661e7419e6596803ed/pillow-12.2.0-cp314-cp314t-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:8e9c4f5b3c546fa3458a29ab22646c1c6c787ea8f5ef51300e5a60300736905e", size = 7116720, upload-time = "2026-04-01T14:45:39.258Z" },
+ { url = "https://files.pythonhosted.org/packages/46/87/495cc9c30e0129501643f24d320076f4cc54f718341df18cc70ec94c44e1/pillow-12.2.0-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:fb043ee2f06b41473269765c2feae53fc2e2fbf96e5e22ca94fb5ad677856f06", size = 6540498, upload-time = "2026-04-01T14:45:41.879Z" },
+ { url = "https://files.pythonhosted.org/packages/18/53/773f5edca692009d883a72211b60fdaf8871cbef075eaa9d577f0a2f989e/pillow-12.2.0-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:f278f034eb75b4e8a13a54a876cc4a5ab39173d2cdd93a638e1b467fc545ac43", size = 7239413, upload-time = "2026-04-01T14:45:44.705Z" },
+ { url = "https://files.pythonhosted.org/packages/c9/e4/4b64a97d71b2a83158134abbb2f5bd3f8a2ea691361282f010998f339ec7/pillow-12.2.0-cp314-cp314t-win32.whl", hash = "sha256:6bb77b2dcb06b20f9f4b4a8454caa581cd4dd0643a08bacf821216a16d9c8354", size = 6482084, upload-time = "2026-04-01T14:45:47.568Z" },
+ { url = "https://files.pythonhosted.org/packages/ba/13/306d275efd3a3453f72114b7431c877d10b1154014c1ebbedd067770d629/pillow-12.2.0-cp314-cp314t-win_amd64.whl", hash = "sha256:6562ace0d3fb5f20ed7290f1f929cae41b25ae29528f2af1722966a0a02e2aa1", size = 7225152, upload-time = "2026-04-01T14:45:50.032Z" },
+ { url = "https://files.pythonhosted.org/packages/ff/6e/cf826fae916b8658848d7b9f38d88da6396895c676e8086fc0988073aaf8/pillow-12.2.0-cp314-cp314t-win_arm64.whl", hash = "sha256:aa88ccfe4e32d362816319ed727a004423aab09c5cea43c01a4b435643fa34eb", size = 2556579, upload-time = "2026-04-01T14:45:52.529Z" },
+ { url = "https://files.pythonhosted.org/packages/4e/b7/2437044fb910f499610356d1352e3423753c98e34f915252aafecc64889f/pillow-12.2.0-pp311-pypy311_pp73-macosx_10_15_x86_64.whl", hash = "sha256:0538bd5e05efec03ae613fd89c4ce0368ecd2ba239cc25b9f9be7ed426b0af1f", size = 5273969, upload-time = "2026-04-01T14:45:55.538Z" },
+ { url = "https://files.pythonhosted.org/packages/f6/f4/8316e31de11b780f4ac08ef3654a75555e624a98db1056ecb2122d008d5a/pillow-12.2.0-pp311-pypy311_pp73-macosx_11_0_arm64.whl", hash = "sha256:394167b21da716608eac917c60aa9b969421b5dcbbe02ae7f013e7b85811c69d", size = 4659674, upload-time = "2026-04-01T14:45:58.093Z" },
+ { url = "https://files.pythonhosted.org/packages/d4/37/664fca7201f8bb2aa1d20e2c3d5564a62e6ae5111741966c8319ca802361/pillow-12.2.0-pp311-pypy311_pp73-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:5d04bfa02cc2d23b497d1e90a0f927070043f6cbf303e738300532379a4b4e0f", size = 5288479, upload-time = "2026-04-01T14:46:01.141Z" },
+ { url = "https://files.pythonhosted.org/packages/49/62/5b0ed78fce87346be7a5cfcfaaad91f6a1f98c26f86bdbafa2066c647ef6/pillow-12.2.0-pp311-pypy311_pp73-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:0c838a5125cee37e68edec915651521191cef1e6aa336b855f495766e77a366e", size = 7032230, upload-time = "2026-04-01T14:46:03.874Z" },
+ { url = "https://files.pythonhosted.org/packages/c3/28/ec0fc38107fc32536908034e990c47914c57cd7c5a3ece4d8d8f7ffd7e27/pillow-12.2.0-pp311-pypy311_pp73-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:4a6c9fa44005fa37a91ebfc95d081e8079757d2e904b27103f4f5fa6f0bf78c0", size = 5355404, upload-time = "2026-04-01T14:46:06.33Z" },
+ { url = "https://files.pythonhosted.org/packages/5e/8b/51b0eddcfa2180d60e41f06bd6d0a62202b20b59c68f5a132e615b75aecf/pillow-12.2.0-pp311-pypy311_pp73-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:25373b66e0dd5905ed63fa3cae13c82fbddf3079f2c8bf15c6fb6a35586324c1", size = 6002215, upload-time = "2026-04-01T14:46:08.83Z" },
+ { url = "https://files.pythonhosted.org/packages/bc/60/5382c03e1970de634027cee8e1b7d39776b778b81812aaf45b694dfe9e28/pillow-12.2.0-pp311-pypy311_pp73-win_amd64.whl", hash = "sha256:bfa9c230d2fe991bed5318a5f119bd6780cda2915cca595393649fc118ab895e", size = 7080946, upload-time = "2026-04-01T14:46:11.734Z" },
]
[[package]]
@@ -1030,6 +1038,35 @@ wheels = [
{ url = "https://files.pythonhosted.org/packages/16/cd/0731490946e037e954ef83719f07c7672cf32bc90dd9c75201c40b827664/pyftdi-0.57.1-py3-none-any.whl", hash = "sha256:efd3f5a7d43202dc883ff261a7b1cb4dcbbe65b19628f8603a8b1183a7bc2841", size = 146180, upload-time = "2025-08-14T15:59:16.164Z" },
]
+[[package]]
+name = "pygame"
+version = "2.6.1"
+source = { registry = "https://pypi.org/simple" }
+sdist = { url = "https://files.pythonhosted.org/packages/49/cc/08bba60f00541f62aaa252ce0cfbd60aebd04616c0b9574f755b583e45ae/pygame-2.6.1.tar.gz", hash = "sha256:56fb02ead529cee00d415c3e007f75e0780c655909aaa8e8bf616ee09c9feb1f", size = 14808125, upload-time = "2024-09-29T13:41:34.698Z" }
+wheels = [
+ { url = "https://files.pythonhosted.org/packages/c4/ca/8f367cb9fe734c4f6f6400e045593beea2635cd736158f9fabf58ee14e3c/pygame-2.6.1-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:20349195326a5e82a16e351ed93465a7845a7e2a9af55b7bc1b2110ea3e344e1", size = 13113753, upload-time = "2024-09-29T14:26:13.751Z" },
+ { url = "https://files.pythonhosted.org/packages/83/47/6edf2f890139616b3219be9cfcc8f0cb8f42eb15efd59597927e390538cb/pygame-2.6.1-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:f3935459109da4bb0b3901da9904f0a3e52028a3332a355d298b1673a334cf21", size = 12378146, upload-time = "2024-09-29T14:26:22.456Z" },
+ { url = "https://files.pythonhosted.org/packages/00/9e/0d8aa8cf93db2d2ee38ebaf1c7b61d0df36ded27eb726221719c150c673d/pygame-2.6.1-cp311-cp311-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:c31dbdb5d0217f32764797d21c2752e258e5fb7e895326538d82b5f75a0cd856", size = 13611760, upload-time = "2024-09-29T11:10:47.317Z" },
+ { url = "https://files.pythonhosted.org/packages/d7/9e/d06adaa5cc65876bcd7a24f59f67e07f7e4194e6298130024ed3fb22c456/pygame-2.6.1-cp311-cp311-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:173badf82fa198e6888017bea40f511cb28e69ecdd5a72b214e81e4dcd66c3b1", size = 14298054, upload-time = "2024-09-29T11:39:53.891Z" },
+ { url = "https://files.pythonhosted.org/packages/7a/a1/9ae2852ebd3a7cc7d9ae7ff7919ab983e4a5c1b7a14e840732f23b2b48f6/pygame-2.6.1-cp311-cp311-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:ce8cc108b92de9b149b344ad2e25eedbe773af0dc41dfb24d1f07f679b558c60", size = 13977107, upload-time = "2024-09-29T11:39:56.831Z" },
+ { url = "https://files.pythonhosted.org/packages/31/df/6788fd2e9a864d0496a77670e44a7c012184b7a5382866ab0e60c55c0f28/pygame-2.6.1-cp311-cp311-win32.whl", hash = "sha256:811e7b925146d8149d79193652cbb83e0eca0aae66476b1cb310f0f4226b8b5c", size = 10250863, upload-time = "2024-09-29T11:44:48.199Z" },
+ { url = "https://files.pythonhosted.org/packages/d2/55/ca3eb851aeef4f6f2e98a360c201f0d00bd1ba2eb98e2c7850d80aabc526/pygame-2.6.1-cp311-cp311-win_amd64.whl", hash = "sha256:91476902426facd4bb0dad4dc3b2573bc82c95c71b135e0daaea072ed528d299", size = 10622016, upload-time = "2024-09-29T12:17:01.545Z" },
+ { url = "https://files.pythonhosted.org/packages/92/16/2c602c332f45ff9526d61f6bd764db5096ff9035433e2172e2d2cadae8db/pygame-2.6.1-cp312-cp312-macosx_10_9_x86_64.whl", hash = "sha256:4ee7f2771f588c966fa2fa8b829be26698c9b4836f82ede5e4edc1a68594942e", size = 13118279, upload-time = "2024-09-29T14:26:30.427Z" },
+ { url = "https://files.pythonhosted.org/packages/cd/53/77ccbc384b251c6e34bfd2e734c638233922449a7844e3c7a11ef91cee39/pygame-2.6.1-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:c8040ea2ab18c6b255af706ec01355c8a6b08dc48d77fd4ee783f8fc46a843bf", size = 12384524, upload-time = "2024-09-29T14:26:49.996Z" },
+ { url = "https://files.pythonhosted.org/packages/06/be/3ed337583f010696c3b3435e89a74fb29d0c74d0931e8f33c0a4246307a9/pygame-2.6.1-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:c47a6938de93fa610accd4969e638c2aebcb29b2fca518a84c3a39d91ab47116", size = 13587123, upload-time = "2024-09-29T11:10:50.072Z" },
+ { url = "https://files.pythonhosted.org/packages/fd/ca/b015586a450db59313535662991b34d24c1f0c0dc149cc5f496573900f4e/pygame-2.6.1-cp312-cp312-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:33006f784e1c7d7e466fcb61d5489da59cc5f7eb098712f792a225df1d4e229d", size = 14275532, upload-time = "2024-09-29T11:39:59.356Z" },
+ { url = "https://files.pythonhosted.org/packages/b9/f2/d31e6ad42d657af07be2ffd779190353f759a07b51232b9e1d724f2cda46/pygame-2.6.1-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:1206125f14cae22c44565c9d333607f1d9f59487b1f1432945dfc809aeaa3e88", size = 13952653, upload-time = "2024-09-29T11:40:01.781Z" },
+ { url = "https://files.pythonhosted.org/packages/f3/42/8ea2a6979e6fa971702fece1747e862e2256d4a8558fe0da6364dd946c53/pygame-2.6.1-cp312-cp312-win32.whl", hash = "sha256:84fc4054e25262140d09d39e094f6880d730199710829902f0d8ceae0213379e", size = 10252421, upload-time = "2024-09-29T11:14:26.877Z" },
+ { url = "https://files.pythonhosted.org/packages/5f/90/7d766d54bb95939725e9a9361f9c06b0cfbe3fe100aa35400f0a461a278a/pygame-2.6.1-cp312-cp312-win_amd64.whl", hash = "sha256:3a9e7396be0d9633831c3f8d5d82dd63ba373ad65599628294b7a4f8a5a01a65", size = 10624591, upload-time = "2024-09-29T11:52:54.489Z" },
+ { url = "https://files.pythonhosted.org/packages/e1/91/718acf3e2a9d08a6ddcc96bd02a6f63c99ee7ba14afeaff2a51c987df0b9/pygame-2.6.1-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:ae6039f3a55d800db80e8010f387557b528d34d534435e0871326804df2a62f2", size = 13090765, upload-time = "2024-09-29T14:27:02.377Z" },
+ { url = "https://files.pythonhosted.org/packages/0e/c6/9cb315de851a7682d9c7568a41ea042ee98d668cb8deadc1dafcab6116f0/pygame-2.6.1-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:2a3a1288e2e9b1e5834e425bedd5ba01a3cd4902b5c2bff8ed4a740ccfe98171", size = 12381704, upload-time = "2024-09-29T14:27:10.228Z" },
+ { url = "https://files.pythonhosted.org/packages/9f/8f/617a1196e31ae3b46be6949fbaa95b8c93ce15e0544266198c2266cc1b4d/pygame-2.6.1-cp313-cp313-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:27eb17e3dc9640e4b4683074f1890e2e879827447770470c2aba9f125f74510b", size = 13581091, upload-time = "2024-09-29T11:30:27.653Z" },
+ { url = "https://files.pythonhosted.org/packages/3b/87/2851a564e40a2dad353f1c6e143465d445dab18a95281f9ea458b94f3608/pygame-2.6.1-cp313-cp313-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:4c1623180e70a03c4a734deb9bac50fc9c82942ae84a3a220779062128e75f3b", size = 14273844, upload-time = "2024-09-29T11:40:04.138Z" },
+ { url = "https://files.pythonhosted.org/packages/85/b5/aa23aa2e70bcba42c989c02e7228273c30f3b44b9b264abb93eaeff43ad7/pygame-2.6.1-cp313-cp313-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:ef07c0103d79492c21fced9ad68c11c32efa6801ca1920ebfd0f15fb46c78b1c", size = 13951197, upload-time = "2024-09-29T11:40:06.785Z" },
+ { url = "https://files.pythonhosted.org/packages/a6/06/29e939b34d3f1354738c7d201c51c250ad7abefefaf6f8332d962ff67c4b/pygame-2.6.1-cp313-cp313-win32.whl", hash = "sha256:3acd8c009317190c2bfd81db681ecef47d5eb108c2151d09596d9c7ea9df5c0e", size = 10249309, upload-time = "2024-09-29T11:10:23.329Z" },
+ { url = "https://files.pythonhosted.org/packages/7e/11/17f7f319ca91824b86557e9303e3b7a71991ef17fd45286bf47d7f0a38e6/pygame-2.6.1-cp313-cp313-win_amd64.whl", hash = "sha256:813af4fba5d0b2cb8e58f5d95f7910295c34067dcc290d34f1be59c48bd1ea6a", size = 10620084, upload-time = "2024-09-29T11:48:51.587Z" },
+]
+
[[package]]
name = "pygments"
version = "2.19.2"