diff --git a/GUIDE.md b/GUIDE.md
index c600e065a..7e3f24104 100644
--- a/GUIDE.md
+++ b/GUIDE.md
@@ -34,6 +34,9 @@ Don't correct me on things that aren't germane, especially when you're only gues
the current selection. No panel or binding may consume a raw NAV event, and no
`declare_bindings()` row may name `cls=NAV` — it's the one axiom the precedence
resolver doesn't apply to. Enforced by the base `Panel`, not convention.
+- **Immutable at domain boundaries.** Data that crosses from config to application is a frozen dataclass. Do not mutate it.
+- **Lifecycle symmetry.** The code that creates an association must close it. Do not depend on a downstream sweep to clean up.
+- **Domain separation.** Config (`pistomp/config/`), hardware, and application are three separate domains. Hardware objects must not cache config state. Config types must not reference hardware objects.
### Writing code here
diff --git a/blend/__init__.py b/blend/__init__.py
index 2ed494902..79c393ccc 100644
--- a/blend/__init__.py
+++ b/blend/__init__.py
@@ -15,29 +15,8 @@
# You should have received a copy of the GNU Affero General Public License
# along with pi-stomp. If not, see .
-"""
-Blend mode package - Analog input-driven snapshot interpolation.
+"""Blend mode: an analog input interpolates between snapshots.
-This package provides functionality for smoothly interpolating between snapshots
-based on analog input position (expression pedals or tweak encoders).
+Import from the submodules. This package must not re-export them, or
+`pistomp.config` cannot use `blend.types` without a cycle.
"""
-
-from blend.easing import EASING_FUNCTIONS, EasingFunc
-from blend.input_controller import InputController
-from blend.manager import BlendMode
-from blend.parameter_setter import ParameterSetter
-from blend.snapshot import SnapshotManager
-from blend.stop import BlendStop
-from blend.types import BlendSnapshotConfig, NormalizedStops
-
-__all__ = [
- "BlendMode",
- "BlendStop",
- "InputController",
- "SnapshotManager",
- "ParameterSetter",
- "BlendSnapshotConfig",
- "NormalizedStops",
- "EASING_FUNCTIONS",
- "EasingFunc",
-]
diff --git a/blend/input_controller.py b/blend/input_controller.py
index edae5b8b3..c4ac668eb 100644
--- a/blend/input_controller.py
+++ b/blend/input_controller.py
@@ -18,7 +18,7 @@
import logging
from bisect import bisect_right
-import common.token as Token
+from pistomp.controller import ControlType
from blend.easing import EasingFunc
from blend.parameter_setter import ParameterSetter
from blend.stop import BlendStop
@@ -60,7 +60,7 @@ def __init__(
def attach_to_input(self, control: BlendInputProtocol) -> None:
"""Store reference to the blend input controller."""
- if getattr(control, "type", None) == Token.VOLUME:
+ if control.type == ControlType.VOLUME:
raise ValueError(f"Input {control.id} is a VOLUME controller and cannot be used for blend mode")
self.controlled_input = control
logging.info(f"Attached blend mode to {type(control).__name__} {control.id}")
diff --git a/blend/snapshot.py b/blend/snapshot.py
index 82f56fd56..d28bbace9 100644
--- a/blend/snapshot.py
+++ b/blend/snapshot.py
@@ -20,6 +20,7 @@
import json
import logging
import pistomp.httpclient as req
+from collections.abc import Sequence
from pathlib import Path
from blend.types import (
@@ -89,7 +90,7 @@ def parse_snapshot_data(snapshots_json: SnapshotsJson, snapshot_index: int) -> S
@staticmethod
def sync_blend_snapshots(
bundle_path: Path,
- blend_configs: list[BlendSnapshotConfig] | None,
+ blend_configs: Sequence[BlendSnapshotConfig] | None,
root_uri: str,
) -> dict[str, int]:
"""Ensure each configured blend snapshot exists as an empty entry.
diff --git a/blend/types.py b/blend/types.py
index 5d9eea5b6..eb720dd90 100644
--- a/blend/types.py
+++ b/blend/types.py
@@ -73,6 +73,7 @@ class BlendInputProtocol(Protocol):
"""Protocol for blend mode input sources (expression pedal or encoder)."""
id: int
+ type: str | None
def get_normalized_value(self) -> float: ...
diff --git a/common/contexts.py b/common/contexts.py
index 547a3c3cf..f21bf9923 100644
--- a/common/contexts.py
+++ b/common/contexts.py
@@ -15,24 +15,9 @@
# You should have received a copy of the GNU Affero General Public License
# along with pi-stomp. If not, see .
-"""What a control does is declared data, not per-panel `if` chains: a
-BindingDecl names a ControlRef + EventKind, a closed Effect union to fire,
-and the ContextRef (PANEL/BLEND/PEDALBOARD/SYSTEM) that owns it. NAV is the
-one axiom excluded from this entirely — see uilib/panel.py's Panel.handle.
-
-ContextStack.resolve walks a fixed per-ControlClass chain (_CHAINS below,
-highest precedence first) and returns the winning row for a (control,
-event_kind) pair, tagging every row it passed over ACTIVE/SHADOWED/ORPHANED
-(ShadowState) so a shadowed binding is visible rather than silently dead —
-this same resolved answer is what on-screen badges render from (never a
-widget's own guess). Consumers: pistomp/input/dispatch.py (per-panel
-resolve_local), pistomp/controller_manager.py (the PEDALBOARD layer),
-modalapi/modhandler.py (the BLEND layer). See pistomp/input/README.md for
-how the pieces fit together end to end."""
-
from dataclasses import dataclass, field
from enum import Enum, auto
-from typing import Callable, TypedDict, Union
+from typing import Callable, Union
from common.param_roles import ParamRole
from common.parameter import Symbol
@@ -141,16 +126,6 @@ class RawMidiCcEffect(Effect):
cc: int
-class LongpressActionConfig(TypedDict, total=False):
- """Mapping-form `longpress:` config, exactly one key (enforced by the schema
- in pistomp/config.py). Stays plain data — controller_manager builds the
- Effect at bind time, when the footswitch's channel is resolved."""
-
- midi_CC: int
- preset: int | str # "UP" | "DOWN" |
- pedalboard: str # "UP" | "DOWN"
-
-
@dataclass(frozen=True)
class TapTempoEffect(Effect):
pass
@@ -193,6 +168,8 @@ class BindingDecl:
@dataclass
class ContextLayer:
+ """Mappings of what physical controls do in a given context."""
+
ref: ContextRef
rows: dict[tuple[ControlClass, EventKind], list[BindingDecl]] = field(default_factory=dict)
@@ -226,6 +203,13 @@ def add(self, decl: BindingDecl) -> None:
@dataclass
class ContextStack:
+ """
+ A stack of ContextLayers, bottom (PEDALBOARD) to top, representing
+ modals, panels, and other transient contexts. The stack is mutable; the layers
+ themselves are frozen once built. The topmost layer that has a row for a given
+ control is the one that wins.
+ """
+
layers: list[ContextLayer] # bottom (PEDALBOARD) -> top
def layers_for(self, kind: ContextKind) -> list[ContextLayer]:
diff --git a/common/token.py b/common/token.py
index 78d1b6608..e665c6eaf 100755
--- a/common/token.py
+++ b/common/token.py
@@ -15,50 +15,21 @@
# You should have received a copy of the GNU Affero General Public License
# along with pi-stomp. If not, see .
-ACTION = 'action'
-ADC_INPUT = 'adc_input'
-ANALOG_CONTROLLERS = 'analog_controllers'
-AUTOSYNC = 'autosync'
BANK = 'bank'
BUNDLE = 'bundle'
BYPASS = 'bypass'
CATEGORY = 'category'
-CHANNEL = 'channel'
COLOR = 'color'
-DEBOUNCE_INPUT = 'debounce_input'
-DISABLE = 'disable'
DOWN = 'DOWN'
-ENCODERS = 'encoders'
-EXPRESSION = 'EXPRESSION'
-FOOTSWITCHES = 'footswitches'
-GPIO_INPUT = 'gpio_input'
-GPIO_OUTPUT = 'gpio_output'
-HARDWARE = 'hardware'
ID = 'id'
-KNOB = 'KNOB'
-LEDSTRIP_POSITION = 'ledstrip_position'
LEFT = 'LEFT'
LEFT_RIGHT = 'LEFT_RIGHT'
-LONGPRESS = 'longpress'
-MIDI = 'midi'
-MIDI_CC = 'midi_CC'
-MIDI_CHANNEL = 'midi_channel'
-MIDI_PORT = 'midi_port'
-NAME = 'name'
NAM_CAPTURE_GAIN = 'nam.capture_gain'
NAM_OUTPUT_VOL = 'nam.output_vol'
-NAV = 'nav'
-NONE = 'None'
-PARAMETER = 'parameter'
-PRESET = 'preset'
RIGHT = 'RIGHT'
-TAP_TEMPO = 'tap_tempo'
TUNER_INPUT = 'tuner_input'
TUNER_MUTE = 'tuner_mute'
-THRESHOLD = 'threshold'
TITLE = 'title'
TYPE = 'type'
UP = 'UP'
-VERSION = 'version'
-VOLUME = 'VOLUME'
WELCOME_SEEN = 'welcome_seen'
diff --git a/docs/architecture.md b/docs/architecture.md
index f91e16ce3..a5ca57e7d 100644
--- a/docs/architecture.md
+++ b/docs/architecture.md
@@ -134,16 +134,32 @@ Two config layers merge at pedalboard load time:
```
Default config (setup/config_templates/)
- ↓ loaded at startup, creates hardware
Per-pedalboard config ({bundle}/config.yml)
- ↓ overlaid by hardware.reinit(cfg)
+ ↓ pistomp.config.resolve(default_cfg, bundle)
+ → merge() overlays the two YAML documents
+ → adapt() applies built-in defaults and converts to frozen model types
```
-`reinit()` starts from a copy of the default config, applies defaults (footswitches,
-encoders, MIDI), then overlays any pedalboard-specific overrides. Unspecified fields
-keep their defaults. Analog controls call `initialize()` after the overlay — if
-`autosync: true`, they read the ADC and emit their current position as a MIDI CC to
-prevent state mismatch.
+`Hardware.reinit(PedalboardConfig)` applies the resolved config: MIDI routing,
+footswitch profiles, encoder type/longpress, external MIDI. It does not merge layers.
+A control that the resolved config does not name falls back to its base-config
+binding, so no state of the pedalboard before it can survive.
+`ControllerManager.bind(current)` builds the controller–parameter subscriptions onto
+the `Current`, which owns them; `current.close()` releases them.
+
+### Config package layers
+
+`pistomp/config/` has three layers. Each is a pure function from one type to another.
+
+| Layer | File | Responsibility |
+|---|---|---|
+| File format | `schema_v1.py` | Parses and validates YAML. Knows only YAML structure. |
+| Adapter | `adapt_v1.py` | Converts file-format types to model types. Applies built-in defaults. |
+| Model | `model.py` | Frozen types that the application reads. Knows nothing about YAML. |
+
+Only `adapt_v1.py` changes when the file format changes. The model is stable.
+
+**Three-valued field semantics**: msgspec distinguishes `UNSET` (key absent from YAML) from `None` (explicit `null` in YAML) from a value. In `_merged_entries`, `None` means the pedalboard explicitly clears that section and the function returns `()`. `UNSET` means the pedalboard does not address that section and the function returns the base entries unchanged.
Config files:
- `/home/pistomp/data/config/default_config.yml` (written by firstboot from templates)
@@ -397,7 +413,10 @@ reads the ADC and sends current position on pedalboard load.
- `modalapi/plugin.py` — Plugin representation
**Config & State**
-- `pistomp/config.py` — Config loading/validation
+- `pistomp/config/schema_v1.py` — Parse and validate YAML; defines the file-format struct types
+- `pistomp/config/adapt_v1.py` — Convert file-format types to model types; apply built-in defaults
+- `pistomp/config/model.py` — Frozen domain types the application reads (`LongpressAction`, `FootswitchBinding`, etc.)
+- `pistomp/config/__init__.py` — `resolve()` entry point; `load_cfg_from_file()`
- `pistomp/settings.py` — Persistent YAML key-value store
**Display**
diff --git a/emulator/controls.py b/emulator/controls.py
index 90a38abfb..69a41cc78 100644
--- a/emulator/controls.py
+++ b/emulator/controls.py
@@ -98,7 +98,6 @@ def __init__(self, id, midi_CC, midi_channel, 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, refresh_callback)
self.type = None
- self.cfg = {}
def poll(self):
pass
@@ -111,10 +110,10 @@ def press(self):
class MockAnalogControl(analogmidicontrol.AnalogMidiControl):
"""Expression pedal / knob with no SPI/ADC. Value set externally."""
- def __init__(self, midi_CC, midi_channel, control_type=None, id=None, cfg=None, midiout=None):
+ def __init__(self, midi_CC, midi_channel, control_type=None, id=None, midiout=None):
super().__init__(spi=None, adc_channel=None, tolerance=0,
midi_CC=midi_CC, midi_channel=midi_channel,
- type=control_type, id=id, cfg=cfg)
+ type=control_type, id=id)
self.midiout = midiout
self.value = 64
diff --git a/emulator/hardware_base.py b/emulator/hardware_base.py
index 5d7ed78ae..89513f060 100644
--- a/emulator/hardware_base.py
+++ b/emulator/hardware_base.py
@@ -23,8 +23,6 @@
"""
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
@@ -60,43 +58,20 @@ def init_lcd(self):
)
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):
+ for b in self.config.footswitches:
+ if b.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.refresh_callback)
+ fs = MockFootswitch(b.id, b.midi_CC, b.midi_channel, self.refresh_callback)
self.footswitches.append(fs)
- if midi_cc is not None:
- key = "%d:%d" % (midi_channel, midi_cc)
- self.controllers[key] = fs
+ self.register_controller(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):
+ for b in self.config.analog_controls:
+ if b.disable or b.midi_CC is None:
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, control_type, id_, c)
+ ctrl = MockAnalogControl(b.midi_CC, b.midi_channel, b.type, b.id)
self.analog_controls.append(ctrl)
- key = "%d:%d" % (midi_channel, midi_cc)
- self.controllers[key] = ctrl
+ self.register_controller(ctrl)
def init_relays(self):
self.relay = StubRelay()
diff --git a/emulator/hardware_v2.py b/emulator/hardware_v2.py
index 09cd3e9cd..0683c0553 100644
--- a/emulator/hardware_v2.py
+++ b/emulator/hardware_v2.py
@@ -21,7 +21,7 @@
footswitches, one pot, one expression pedal, no relay interaction.
"""
-import common.token as Token
+from pistomp.controller import ControlType
from emulator.hardware_base import EmulatorHardwareBase
from emulator.controls import MockEncoder
@@ -39,7 +39,7 @@ def __init__(self, cfg, handler, midiout, refresh_callback):
self.init_analog_controls()
def init_encoders(self):
- nav = MockEncoder(type=Token.NAV, id=0)
+ nav = MockEncoder(type=ControlType.NAV, id=0)
self.encoders.append(nav)
self.nav_encoder = nav
# tweak_encoders and volume_encoder stay None/[] — v2 has no extras
diff --git a/emulator/hardware_v3.py b/emulator/hardware_v3.py
index f94eec656..09314e8a5 100644
--- a/emulator/hardware_v3.py
+++ b/emulator/hardware_v3.py
@@ -21,7 +21,7 @@
one volume encoder, four footswitches, expression pedal.
"""
-import common.token as Token
+from pistomp.controller import ControlType
from emulator.hardware_base import EmulatorHardwareBase
from emulator.controls import MockEncoder, MockEncoderMidi
@@ -41,23 +41,22 @@ def __init__(self, cfg, handler, midiout, refresh_callback):
self.init_analog_controls()
def init_encoders(self):
- nav = MockEncoder(type=Token.NAV, id=0)
+ nav = MockEncoder(type=ControlType.NAV, id=0)
self.encoders.append(nav)
self.nav_encoder = nav
- cfg = self.default_cfg.copy()
- self.create_encoders(cfg)
+ self.create_encoders(self.config)
def add_encoder(self, id, type, longpress_callback, midi_channel, midi_cc):
"""Called by Hardware.create_encoders() for each encoder in config."""
- if type == Token.VOLUME:
+ if type == ControlType.VOLUME:
enc = MockEncoder(type=type, id=id)
self.volume_encoder = enc
else:
enc = MockEncoderMidi(
midi_channel=midi_channel,
midi_CC=midi_cc,
- type=Token.KNOB,
+ type=ControlType.KNOB,
id=id)
if longpress_callback:
enc.set_longpress(longpress_callback)
diff --git a/modalapi/external_midi.py b/modalapi/external_midi.py
index 18e830354..ba38bcfa5 100644
--- a/modalapi/external_midi.py
+++ b/modalapi/external_midi.py
@@ -58,6 +58,12 @@ def __init__(self):
self.send_delay_ms: int = 10
self._open_failures: dict[str, float] = {}
+ def set_config(self, cfg: ExternalMidiConfig) -> None:
+ """Replace the configuration. Messages of the previous pedalboard go away."""
+ self.enabled = cfg.get("enabled", False)
+ self.send_delay_ms = cfg.get("send_delay_ms", 10)
+ self.messages = dict(cfg.get("messages", {}))
+
def update_config(self, cfg: ExternalMidiConfig | None) -> None:
"""Update configuration incrementally; only fields present are updated."""
if cfg is None:
diff --git a/modalapi/modhandler.py b/modalapi/modhandler.py
index b9bdbee88..9434e6bdb 100644
--- a/modalapi/modhandler.py
+++ b/modalapi/modhandler.py
@@ -30,7 +30,6 @@
from pistomp.httpclient import Response
import subprocess
import sys
-import yaml
from collections import namedtuple
from collections.abc import Callable
from dataclasses import replace
@@ -99,6 +98,8 @@
WebSocketMessage,
)
from modalapi.pedalboard_monitor import FileChangeMonitor, read_pedalboard_bundle
+import pistomp.config as config
+from pistomp.controller import ControlType
from modalapi.version_check import DpkgDriftCheck
from pistomp.controller_manager import ControllerManager
@@ -129,14 +130,6 @@
STARTUP_REST_BACKOFF_S = (0.25, 0.25, 0.5, 1.0, 2.0)
-def _remove_binding_row(layer: ContextLayer, binding_id: str) -> None:
- # Drop any PEDALBOARD-layer row whose control.id matches a learned binding
- # that's being replaced. Scans all event_kind buckets since a re-learn could
- # cross controller classes (footswitch ↔ encoder).
- for (cls, event_kind), rows in list(layer.rows.items()):
- layer.rows[(cls, event_kind)] = [d for d in rows if d.control.id != binding_id]
-
-
class LongpressCcKey(namedtuple("LongpressCcKey", ["channel", "cc"])):
"""(channel, cc) identity for a raw-CC longpress row; tracks what value to
send next. mod-ui's echo reconciles the learned plugin."""
@@ -319,7 +312,7 @@ def bind_volume_encoder(self):
if master is None: # card exposes no master mixer control (e.g. hifiberry)
return
for enc in self.hardware.encoders:
- if enc.type != Token.VOLUME or not isinstance(enc, EncoderController):
+ if enc.type != ControlType.VOLUME or not isinstance(enc, EncoderController):
continue
value = self.audiocard.get_volume_parameter(master)
info = PortInfo(
@@ -401,7 +394,7 @@ def _handle_encoder(self, event: EncoderEvent) -> bool:
# backing plugin parameter, just the audio card.
delta = int(round(event.rotations * effective_multiplier(event.multiplier, c.parameter)))
- if c.type == Token.VOLUME and c.parameter is not None:
+ if c.type == ControlType.VOLUME and c.parameter is not None:
new_value = ParameterSteps.for_parameter(c.parameter).move(delta)
c.parameter.preview(new_value)
self.audiocard.set_volume_parameter(self.audiocard.MASTER, new_value)
@@ -1125,6 +1118,9 @@ def set_current_pedalboard(self, pedalboard):
if self._current is not None and self._current.analog_controllers:
self.lcd.draw_analog_assignments(self.current.analog_controllers)
+ if self._current is not None:
+ self._current.close()
+
# Delete previous "current"
del self._current
@@ -1152,13 +1148,8 @@ def set_current_pedalboard(self, pedalboard):
self._apply_patch(plugin, param_uri, value)
self._pending_dump_patch.clear()
- # Load Pedalboard specific config (overrides default set during initial hardware init)
- config_file = Path(pedalboard.bundle) / "config.yml"
- cfg = None
- if config_file.exists():
- with open(config_file.as_posix(), "r") as ymlfile:
- cfg = yaml.load(ymlfile, Loader=yaml.SafeLoader)
- self.hardware.reinit(cfg)
+ pedalboard_config = config.resolve(self.hardware.default_cfg, pedalboard.bundle)
+ self.hardware.reinit(pedalboard_config)
# Initialize the data and draw on LCD
self.bind_current_pedalboard()
@@ -1180,14 +1171,14 @@ def set_current_pedalboard(self, pedalboard):
# Prepare blend modes if configured (snapshot-based activation)
try:
- blend_configs = cfg.get("blend_snapshots", []) if cfg else []
+ blend_configs = pedalboard_config.blend_snapshots
bundle_path = Path(self.current.pedalboard.bundle)
# Sync all blend snapshots (create/recreate based on config)
snapshot_indices = SnapshotManager.sync_blend_snapshots(bundle_path, blend_configs, self.root_uri)
# Create and prepare BlendMode instances for each blend snapshot
- from blend import BlendMode
+ from blend.manager import BlendMode
for blend_cfg in blend_configs:
snapshot_name = blend_cfg.get("name")
@@ -1266,35 +1257,12 @@ def _publish_plugin_param(self, param: Parameter) -> bool:
return False
return self.ws_bridge.send_parameter(param.instance_id, param.symbol, param.value)
- def _redraw_after_binding(self, controller: Controller | None, is_footswitch: bool) -> None:
- if is_footswitch and controller is not None:
- # Footswitch: redraw just that one switch, not the whole board.
- self.lcd.update_footswitch(controller)
- else:
+ def _rebind_pedalboard(self) -> None:
+ self._controller_manager.bind(self._current)
+ self.lcd.draw_main_panel()
+ if self._current is not None:
self.lcd.draw_analog_assignments(self.current.analog_controllers)
- def _add_learned_binding_row(
- self, plugin: Plugin, param: Parameter, controller: Controller | None, old_binding: str | None
- ) -> None:
- layer = self._controller_manager.effective_table.layers[0]
- if old_binding is not None:
- _remove_binding_row(layer, old_binding)
- if controller is None:
- return
- if isinstance(controller, Footswitch):
- cls, event_kind = ControlClass.FOOTSWITCH, EventKind.PRESS
- else:
- cls, event_kind = ControlClass.ANALOG, EventKind.ROTATE
- assert param.binding is not None
- layer.add(
- BindingDecl(
- control=ControlRef(cls=cls, id=param.binding),
- event_kind=event_kind,
- effects=(ParamEffect(plugin=plugin, symbol=param.symbol),),
- context=layer.ref,
- )
- )
-
def pedalboard_change(self, pedalboard: Pedalboard.Pedalboard) -> None:
logging.info("Pedalboard change")
self.lcd.draw_info_message("Loading...")
diff --git a/modalapistomp.py b/modalapistomp.py
index 7b704d2fc..3ac03ac97 100755
--- a/modalapistomp.py
+++ b/modalapistomp.py
@@ -20,7 +20,6 @@
# Configure logging BEFORE any imports to ensure it takes effect
import logging
import sys
-from typing import Any
import shutil
# Set up logging with format that works well with systemd journal
@@ -100,7 +99,7 @@ def main():
handler = None
midiout = None
- cfg: dict[str, Any] | None = None
+ cfg: config.ConfigDocument | None = None
audiocard: Audiocard | None = None
is_emulator = args.host[0] in EMULATOR_HOSTS
diff --git a/pistomp/analogmidicontrol.py b/pistomp/analogmidicontrol.py
index feb0d3044..51fd3afba 100755
--- a/pistomp/analogmidicontrol.py
+++ b/pistomp/analogmidicontrol.py
@@ -15,7 +15,6 @@
# You should have received a copy of the GNU Affero General Public License
# along with pi-stomp. If not, see .
-from typing import Any
import common.util as util
import pistomp.analogcontrol as analogcontrol
@@ -31,7 +30,7 @@ def as_midi_value(adc_value: int):
class AnalogMidiControl(analogcontrol.AnalogControl, controller.StatefulController):
- def __init__(self, spi, adc_channel, tolerance, midi_CC, midi_channel, type, id=None, cfg=None, autosync=False):
+ def __init__(self, spi, adc_channel, tolerance, midi_CC, midi_channel, type, id=None, autosync=False):
super(AnalogMidiControl, self).__init__(spi, adc_channel, tolerance)
controller.Controller.__init__(self, midi_channel, midi_CC)
self.autosync = autosync
@@ -41,7 +40,6 @@ def __init__(self, spi, adc_channel, tolerance, midi_CC, midi_channel, type, id=
self.last_read = 0
self.midi_value = 0
self.value = None
- self.cfg: dict[str, Any] = cfg or {}
self._connection = AnalogConnectionMonitor()
def set_midi_channel(self, midi_channel):
diff --git a/pistomp/config.py b/pistomp/config.py
deleted file mode 100644
index 10115d268..000000000
--- a/pistomp/config.py
+++ /dev/null
@@ -1,307 +0,0 @@
-# SPDX-License-Identifier: AGPL-3.0-or-later
-#
-# 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 Affero 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 Affero General Public License for more details.
-#
-# You should have received a copy of the GNU Affero General Public License
-# along with pi-stomp. If not, see .
-
-import logging
-import os
-from typing import Any
-
-import yaml
-from jsonschema import validate
-from jsonschema import exceptions
-
-data_dir = '/home/pistomp/data/config'
-
-DEFAULT_CONFIG_FILE = "default_config.yml"
-
-schema = {
- "$schema": "http://json-schema.org/draft-04/schema#",
- "type": "object",
- "properties": {
- "hardware": {
- "type": "object",
- "properties": {
- "version": {
- "type": "number"
- },
- "midi": {
- "type": "object",
- "properties": {
- "channel": {
- "type": "integer",
- "minimum": 1,
- "maximum": 16
- }
- },
- "required": [
- "channel"
- ]
- },
- "footswitches": {
- "type": "array",
- "uniqueItems": True,
- "items": {
- "type": "object",
- "properties": {
- "bypass": {
- "enum": ["LEFT", "RIGHT", "LEFT_RIGHT"]
- },
- "adc_input": {
- "type": "integer"
- },
- "color": {
- "type": "string"
- },
- "debounce_input": {
- "type": "integer"
- },
- "disable": {
- "type": "boolean",
- "description": "Disable this footswitch entirely or per-pedalboard (disabled=True)"
- },
- "gpio_input": {
- "type": "integer"
- },
- "gpio_output": {
- "type": "integer"
- },
- "id": {
- "type": "integer"
- },
- "ledstrip_position": {
- "type": "integer"
- },
- "longpress": {
- "oneOf": [
- {
- "type": "string",
- "enum": ["next_snapshot", "previous_snapshot", "toggle_bypass",
- "toggle_tap_tempo_enable", "toggle_tuner_enable", "next_pedalboard",
- "previous_pedalboard"]
- },
- {
- "type": "array",
- "items": {
- "type": "string",
- "enum": ["next_snapshot", "previous_snapshot", "toggle_bypass",
- "toggle_tap_tempo_enable", "toggle_tuner_enable", "next_pedalboard",
- "previous_pedalboard"]
- }
- },
- {
- "type": "object",
- "additionalProperties": False,
- "minProperties": 1,
- "maxProperties": 1,
- "properties": {
- "midi_CC": {"type": "integer", "minimum": 0, "maximum": 127},
- "preset": {
- "oneOf": [
- {"type": "integer"},
- {"type": "string", "enum": ["UP", "DOWN"]}
- ]
- },
- "pedalboard": {"type": "string", "enum": ["UP", "DOWN"]}
- }
- }
- ]
- },
- "midi_CC": {
- "type": "integer"
- },
- "midi_port": {
- "type": "string",
- "description": "Send MIDI to this external port instead of the virtual MIDI Through port; falls back to virtual if the device is unavailable (must match a port in external_midi)"
- },
- "midi_channel": {
- "type": "integer",
- "minimum": 0,
- "maximum": 15,
- "description": "Override MIDI channel for this footswitch; required when midi_port is set, since external devices rarely share the hardware default channel"
- },
- "preset": {
- "oneOf": [
- {
- "type": "integer"
- },
- {
- "type": "string",
- "enum": ["UP", "DOWN"]
- }
- ]
- },
- "tap_tempo": {
- "enum": ["set_mod_tap_tempo"]
- }
- },
- "required": [
- "id",
- ],
- "dependencies": {
- "midi_port": ["midi_channel"]
- }
- }
- },
- "analog_controllers": {
- "type": "array",
- "uniqueItems": True,
- "items": {
- "type": "object",
- "properties": {
- "adc_input": {
- "type": "integer"
- },
- "id": {
- "type": "integer"
- },
- "midi_CC": {
- "type": "integer"
- },
- "midi_port": {
- "type": "string",
- "description": "Send MIDI to this external port instead of the virtual MIDI Through port; falls back to virtual if the device is unavailable (must match a port in external_midi)"
- },
- "midi_channel": {
- "type": "integer",
- "minimum": 0,
- "maximum": 15,
- "description": "Override MIDI channel for this controller; required when midi_port is set, since external devices rarely share the hardware default channel"
- },
- "threshold": {
- "type": "integer",
- "minimum": 0,
- "maximum": 127
- },
- "type": {
- "enum": ["KNOB", "EXPRESSION"]
- },
- "autosync": {
- "type": "boolean"
- }
- },
- "required": [
- "adc_input",
- "midi_CC"
- ],
- "dependencies": {
- "midi_port": ["midi_channel"]
- }
- }
- },
- "encoders": {
- "type": "array",
- "uniqueItems": True,
- "items": {
- "type": "object",
- "properties": {
- "id": {
- "type": "integer"
- },
- "midi_CC": {
- "type": "integer"
- },
- "midi_port": {
- "type": "string",
- "description": "Send MIDI to this external port instead of the virtual MIDI Through port; falls back to virtual if the device is unavailable (must be the device name)"
- },
- "midi_channel": {
- "type": "integer",
- "minimum": 0,
- "maximum": 15,
- "description": "Override MIDI channel for this encoder; required when midi_port is set, since external devices rarely share the hardware default channel"
- },
- "type": {
- "enum": ["KNOB", "VOLUME"]
- },
- "longpress": {
- "type": "string"
- }
- },
- "required": [
- "id"
- ],
- "dependencies": {
- "midi_port": ["midi_channel"]
- }
- }
- },
- "external_midi": {
- "type": "object",
- "properties": {
- "enabled": {
- "type": "boolean"
- },
- "send_delay_ms": {
- "type": "integer",
- "minimum": 0
- },
- "messages": {
- "type": "object",
- "additionalProperties": {
- "type": "array",
- "items": {
- "type": "array",
- "items": {
- "type": "integer",
- "minimum": 0,
- "maximum": 255
- }
- }
- }
- }
- }
- }
- },
- "required": [
- "version",
- "midi",
- ]
- }
- },
- "required": [
- "hardware"
- ]
-}
-
-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:
- cfg = yaml.load(ymlfile, Loader=yaml.SafeLoader)
-
- # Now validate. Error message if problem found but it won't be fatal
- try:
- validate(instance=cfg, schema=schema)
- except exceptions.SchemaError as e:
- msg = ("Badly formatted schema in: %s %s" % (os.path.basename(__file__), e.message))
- logging.error(msg)
- except exceptions.ValidationError as e:
- msg = ("Config file error in: %s\n%s\n%s" % (default_config_file, e.schema_path, e.message))
- logging.error(msg)
-
- return cfg
diff --git a/pistomp/config/__init__.py b/pistomp/config/__init__.py
new file mode 100644
index 000000000..b06782d19
--- /dev/null
+++ b/pistomp/config/__init__.py
@@ -0,0 +1,54 @@
+# SPDX-License-Identifier: AGPL-3.0-or-later
+#
+# 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 Affero 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 Affero General Public License for more details.
+#
+# You should have received a copy of the GNU Affero General Public License
+# along with pi-stomp. If not, see .
+
+"""Config loading: the file format, the adapter, and the model.
+
+Import the model types from `pistomp.config.model`. This module holds the
+entry points that read a file.
+"""
+
+from pathlib import Path
+
+from pistomp.config.adapt_v1 import adapt
+from pistomp.config.model import PedalboardConfig
+from pistomp.config.schema_v1 import (
+ ConfigDocument,
+ ConfigError,
+ hardware_version,
+ json_schema,
+ load_cfg_from_file,
+ load_default_cfg,
+ merge,
+ parse,
+ read_bundle_config,
+)
+
+__all__ = [
+ "ConfigDocument",
+ "ConfigError",
+ "hardware_version",
+ "json_schema",
+ "load_cfg_from_file",
+ "load_default_cfg",
+ "parse",
+ "resolve",
+]
+
+
+def resolve(default: ConfigDocument, bundle: str | Path | None = None) -> PedalboardConfig:
+ """The configuration in effect for one pedalboard bundle."""
+ return adapt(merge(default, read_bundle_config(bundle)))
diff --git a/pistomp/config/adapt_v1.py b/pistomp/config/adapt_v1.py
new file mode 100644
index 000000000..d771d587e
--- /dev/null
+++ b/pistomp/config/adapt_v1.py
@@ -0,0 +1,159 @@
+# SPDX-License-Identifier: AGPL-3.0-or-later
+#
+# 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 Affero 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 Affero General Public License for more details.
+#
+# You should have received a copy of the GNU Affero General Public License
+# along with pi-stomp. If not, see .
+
+"""Turn a version 1 document into the types that the application reads.
+
+The built-in defaults live here, because a later file format can default
+differently. UNSET does not cross this boundary.
+"""
+
+from __future__ import annotations
+
+from typing import TypeVar
+
+from msgspec import UNSET, UnsetType
+
+from modalapi.external_midi import ExternalMidiConfig
+from pistomp.config import schema_v1 as v1
+from pistomp.config.model import (
+ AnalogBinding,
+ ControlType,
+ EncoderBinding,
+ FootswitchBinding,
+ LongpressBoard,
+ LongpressMidiCC,
+ LongpressPreset,
+ LongpressSpec,
+ PedalboardConfig,
+ PresetAction,
+ PresetStep,
+)
+
+_T = TypeVar("_T")
+
+RELAY_BYPASS = ("LEFT", "LEFT_RIGHT")
+
+
+def _value(value: _T | None | UnsetType, default: _T) -> _T:
+ """Read a field that cannot hold null. Absent and null give the default."""
+ if value is UNSET or value is None:
+ return default
+ return value
+
+
+def _nullable(value: _T | None | UnsetType) -> _T | None:
+ """Read a field where null clears the value."""
+ return None if value is UNSET else value
+
+
+def _midi_cc(value: int | str | None | UnsetType) -> int | None:
+ """The string 'None' and null both mean no CC."""
+ if value is UNSET or value is None or isinstance(value, str):
+ return None
+ return value
+
+
+def _longpress(value: v1.Longpress | None | UnsetType) -> LongpressSpec | None:
+ if value is UNSET or value is None:
+ return None
+ if isinstance(value, str):
+ return (value,) # one action name; a chord is a list
+ if isinstance(value, list):
+ return tuple(value)
+ if value.midi_CC is not UNSET:
+ return LongpressMidiCC(cc=value.midi_CC)
+ if value.preset is not UNSET and value.preset is not None:
+ p = value.preset
+ return LongpressPreset(preset=PresetStep(p) if isinstance(p, str) else p)
+ return LongpressBoard(direction=str(value.pedalboard))
+
+
+def _preset(value: int | str | None | UnsetType) -> PresetAction | None:
+ if value is UNSET or value is None:
+ return None
+ return PresetStep(value) if isinstance(value, str) else value
+
+
+def _footswitch(entry: v1.FootswitchEntry, midi_channel: int) -> FootswitchBinding:
+ return FootswitchBinding(
+ id=entry.id,
+ adc_input=_nullable(entry.adc_input),
+ gpio_input=_nullable(entry.gpio_input),
+ debounce_input=_nullable(entry.debounce_input),
+ gpio_output=_nullable(entry.gpio_output),
+ ledstrip_position=_nullable(entry.ledstrip_position),
+ tap_tempo=_nullable(entry.tap_tempo),
+ midi_CC=_midi_cc(entry.midi_CC),
+ midi_channel=_value(entry.midi_channel, midi_channel),
+ midi_port=_nullable(entry.midi_port),
+ longpress=_longpress(entry.longpress),
+ preset=_preset(entry.preset),
+ uses_relay=_nullable(entry.bypass) in RELAY_BYPASS,
+ color=_nullable(entry.color),
+ disable=_value(entry.disable, False),
+ )
+
+
+def _encoder(entry: v1.EncoderEntry, midi_channel: int) -> EncoderBinding:
+ control_type = ControlType(_value(entry.type, ControlType.KNOB))
+ return EncoderBinding(
+ id=entry.id,
+ type=control_type,
+ midi_CC=None if control_type is ControlType.VOLUME else _midi_cc(entry.midi_CC),
+ midi_channel=_value(entry.midi_channel, midi_channel),
+ midi_port=_nullable(entry.midi_port),
+ longpress=_nullable(entry.longpress),
+ disable=_value(entry.disable, False),
+ )
+
+
+def _analog_control(entry: v1.AnalogEntry, midi_channel: int) -> AnalogBinding:
+ return AnalogBinding(
+ id=entry.id,
+ adc_input=_nullable(entry.adc_input),
+ type=ControlType(_value(entry.type, ControlType.KNOB)),
+ threshold=_value(entry.threshold, 16),
+ autosync=_value(entry.autosync, False),
+ midi_CC=_midi_cc(entry.midi_CC),
+ midi_channel=_value(entry.midi_channel, midi_channel),
+ midi_port=_nullable(entry.midi_port),
+ disable=_value(entry.disable, False),
+ )
+
+
+def _external_midi(section: v1.ExternalMidiSection) -> ExternalMidiConfig:
+ return ExternalMidiConfig(
+ enabled=_value(section.enabled, False),
+ send_delay_ms=_value(section.send_delay_ms, 10),
+ messages=_value(section.messages, {}),
+ )
+
+
+def adapt(document: v1.MergedDocument) -> PedalboardConfig:
+ """Apply the built-in defaults and normalise the file vocabulary."""
+ file_channel = _value(document.midi_channel, 1)
+ # mod reads a channel one higher than sent, so the file value is 1-based.
+ midi_channel = file_channel - 1 if file_channel > 0 else 0
+ return PedalboardConfig(
+ version=_value(document.version, 0.0),
+ midi_channel=midi_channel,
+ external_midi=_external_midi(document.external_midi),
+ blend_snapshots=tuple(document.blend_snapshots),
+ footswitches=tuple(_footswitch(e, midi_channel) for e in document.footswitches),
+ encoders=tuple(_encoder(e, midi_channel) for e in document.encoders),
+ analog_controls=tuple(_analog_control(e, midi_channel) for e in document.analog_controllers),
+ )
diff --git a/pistomp/config/model.py b/pistomp/config/model.py
new file mode 100644
index 000000000..95e1039e5
--- /dev/null
+++ b/pistomp/config/model.py
@@ -0,0 +1,144 @@
+# SPDX-License-Identifier: AGPL-3.0-or-later
+#
+# 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 Affero 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 Affero General Public License for more details.
+#
+# You should have received a copy of the GNU Affero General Public License
+# along with pi-stomp. If not, see .
+
+"""The control configuration that the application reads.
+
+Every field holds a value. The file format, the merge of the layers and the
+built-in defaults are not visible here. An adapter module builds these types
+from one schema version, so a new file format changes the adapter only.
+"""
+
+from __future__ import annotations
+
+from dataclasses import dataclass
+from enum import StrEnum
+from typing import TypeAlias
+
+from blend.types import BlendSnapshotConfig
+from modalapi.external_midi import ExternalMidiConfig
+from pistomp.controller import ControlType
+
+
+class PresetStep(StrEnum):
+ UP = "UP"
+ DOWN = "DOWN"
+
+
+PresetAction: TypeAlias = PresetStep | int
+
+
+# U+2212: the ASCII hyphen is half the width of "+" and reads as a dash, not
+# an operator, next to it.
+MINUS = "−"
+
+
+@dataclass(frozen=True)
+class LongpressMidiCC:
+ cc: int
+
+ def label(self) -> str:
+ return f"MIDI CC {self.cc}"
+
+
+@dataclass(frozen=True)
+class LongpressPreset:
+ preset: PresetAction
+
+ def label(self) -> str:
+ match self.preset:
+ case PresetStep.UP:
+ return "Snapshot +"
+ case PresetStep.DOWN:
+ return f"Snapshot {MINUS}"
+ case _:
+ return f"Snapshot {self.preset}"
+
+
+@dataclass(frozen=True)
+class LongpressBoard:
+ direction: str
+
+ def label(self) -> str:
+ return "Pedalboard +" if self.direction == PresetStep.UP else f"Pedalboard {MINUS}"
+
+
+# The mapping form of longpress. The chord form is a tuple of action names.
+LongpressAction: TypeAlias = LongpressMidiCC | LongpressPreset | LongpressBoard
+LongpressSpec: TypeAlias = tuple[str, ...] | LongpressAction
+
+
+@dataclass(frozen=True)
+class FootswitchBinding:
+ id: int
+ adc_input: int | None
+ gpio_input: int | None
+ debounce_input: int | None
+ gpio_output: int | None
+ ledstrip_position: int | None
+ tap_tempo: str | None
+ midi_CC: int | None
+ midi_channel: int
+ midi_port: str | None
+ longpress: LongpressSpec | None
+ preset: PresetAction | None
+ uses_relay: bool
+ color: str | None
+ disable: bool
+
+
+@dataclass(frozen=True)
+class EncoderBinding:
+ id: int
+ type: ControlType
+ midi_CC: int | None
+ midi_channel: int
+ midi_port: str | None
+ longpress: str | None
+ disable: bool
+
+
+@dataclass(frozen=True)
+class AnalogBinding:
+ id: int
+ adc_input: int | None
+ type: ControlType
+ threshold: int
+ autosync: bool
+ midi_CC: int | None
+ midi_channel: int
+ midi_port: str | None
+ disable: bool
+
+
+@dataclass(frozen=True)
+class PedalboardConfig:
+ version: float
+ midi_channel: int
+ external_midi: ExternalMidiConfig
+ blend_snapshots: tuple[BlendSnapshotConfig, ...]
+ footswitches: tuple[FootswitchBinding, ...]
+ encoders: tuple[EncoderBinding, ...]
+ analog_controls: tuple[AnalogBinding, ...]
+
+ def footswitch(self, control_id: int) -> FootswitchBinding | None:
+ return next((f for f in self.footswitches if f.id == control_id), None)
+
+ def encoder(self, control_id: int) -> EncoderBinding | None:
+ return next((e for e in self.encoders if e.id == control_id), None)
+
+ def analog_control(self, control_id: int) -> AnalogBinding | None:
+ return next((a for a in self.analog_controls if a.id == control_id), None)
diff --git a/pistomp/config/schema_v1.py b/pistomp/config/schema_v1.py
new file mode 100644
index 000000000..763355f04
--- /dev/null
+++ b/pistomp/config/schema_v1.py
@@ -0,0 +1,378 @@
+# SPDX-License-Identifier: AGPL-3.0-or-later
+#
+# 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 Affero 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 Affero General Public License for more details.
+#
+# You should have received a copy of the GNU Affero General Public License
+# along with pi-stomp. If not, see .
+
+"""Version 1 of the config file format.
+
+These types mirror the YAML shape. A field holds UNSET when the file does not
+contain the key. Two documents merge on the keys that they contain, so an
+absent key keeps the lower layer and an explicit null clears the value.
+
+Nothing here knows what the application does with a value. pistomp.config.adapt_v1
+turns a merged document into the pistomp.config.model types.
+"""
+
+from __future__ import annotations
+
+import logging
+import os
+from pathlib import Path
+from collections.abc import Callable
+from typing import Annotated, Any, Literal, TypeVar
+
+import msgspec
+from msgspec import UNSET, Meta, Struct, UnsetType, convert
+from msgspec.structs import asdict, replace
+
+from blend.types import BlendSnapshotConfig
+
+data_dir = "/home/pistomp/data/config"
+
+DEFAULT_CONFIG_FILE = "default_config.yml"
+
+MidiCC = Annotated[int, Meta(ge=0, le=127)]
+MidiChannel = Annotated[int, Meta(ge=0, le=15)]
+FileMidiChannel = Annotated[int, Meta(ge=1, le=16)]
+
+
+FootswitchDisable = Annotated[
+ bool | None | UnsetType, Meta(description="Disable this footswitch entirely or per-pedalboard (disabled=True)")
+]
+
+FootswitchMidiPort = Annotated[
+ str | None | UnsetType,
+ Meta(
+ description=(
+ "Send MIDI to this external port instead of the virtual MIDI Through port; "
+ "falls back to virtual if the device is unavailable (must match a port in external_midi)"
+ )
+ ),
+]
+
+FootswitchMidiChannel = Annotated[
+ MidiChannel | None | UnsetType,
+ Meta(
+ description=(
+ "Override MIDI channel for this footswitch; required when midi_port is set, "
+ "since external devices rarely share the hardware default channel"
+ )
+ ),
+]
+
+AnalogMidiPort = Annotated[
+ str | None | UnsetType,
+ Meta(
+ description=(
+ "Send MIDI to this external port instead of the virtual MIDI Through port; "
+ "falls back to virtual if the device is unavailable (must match a port in external_midi)"
+ )
+ ),
+]
+
+AnalogMidiChannel = Annotated[
+ MidiChannel | None | UnsetType,
+ Meta(
+ description=(
+ "Override MIDI channel for this controller; required when midi_port is set, "
+ "since external devices rarely share the hardware default channel"
+ )
+ ),
+]
+
+EncoderMidiPort = Annotated[
+ str | None | UnsetType,
+ Meta(
+ description=(
+ "Send MIDI to this external port instead of the virtual MIDI Through port; "
+ "falls back to virtual if the device is unavailable (must be the device name)"
+ )
+ ),
+]
+
+EncoderMidiChannel = Annotated[
+ MidiChannel | None | UnsetType,
+ Meta(
+ description=(
+ "Override MIDI channel for this encoder; required when midi_port is set, "
+ "since external devices rarely share the hardware default channel"
+ )
+ ),
+]
+
+NoneToken = Literal["None"]
+Step = Literal["UP", "DOWN"]
+BypassMode = Literal["LEFT", "RIGHT", "LEFT_RIGHT"]
+SwitchType = Literal["KNOB", "EXPRESSION"]
+EncoderType = Literal["KNOB", "VOLUME"]
+TapTempoAction = Literal["set_mod_tap_tempo"]
+
+LongpressName = Literal[
+ "next_snapshot",
+ "previous_snapshot",
+ "toggle_bypass",
+ "toggle_tap_tempo_enable",
+ "toggle_tuner_enable",
+ "next_pedalboard",
+ "previous_pedalboard",
+]
+
+
+class LongpressMapping(Struct, frozen=True, forbid_unknown_fields=True):
+ """The argument-carrying long-press form. Exactly one key is allowed."""
+
+ midi_CC: MidiCC | UnsetType = UNSET
+ preset: int | Step | UnsetType = UNSET
+ pedalboard: Step | UnsetType = UNSET
+
+ def __post_init__(self) -> None:
+ given = [v for v in asdict(self).values() if v is not UNSET]
+ if len(given) != 1:
+ raise ValueError("a longpress mapping needs exactly one of midi_CC, preset, pedalboard")
+
+
+Longpress = LongpressName | list[LongpressName] | LongpressMapping
+
+
+def _check_routing(midi_port: str | None | UnsetType, midi_channel: int | None | UnsetType) -> None:
+ """An external device rarely shares the hardware default channel."""
+ if midi_port is not UNSET and midi_port is not None and midi_channel is UNSET:
+ raise ValueError("midi_port needs midi_channel")
+
+
+class FootswitchEntry(Struct, frozen=True, forbid_unknown_fields=True):
+ id: int
+ adc_input: int | None | UnsetType = UNSET
+ gpio_input: int | None | UnsetType = UNSET
+ debounce_input: int | None | UnsetType = UNSET
+ gpio_output: int | None | UnsetType = UNSET
+ ledstrip_position: int | None | UnsetType = UNSET
+ tap_tempo: TapTempoAction | None | UnsetType = UNSET
+ midi_CC: MidiCC | NoneToken | None | UnsetType = UNSET
+ midi_channel: FootswitchMidiChannel = UNSET
+ midi_port: FootswitchMidiPort = UNSET
+ longpress: Longpress | None | UnsetType = UNSET
+ preset: int | Step | None | UnsetType = UNSET
+ bypass: BypassMode | None | UnsetType = UNSET
+ color: str | None | UnsetType = UNSET
+ disable: FootswitchDisable = UNSET
+
+ def __post_init__(self) -> None:
+ _check_routing(self.midi_port, self.midi_channel)
+
+
+class EncoderEntry(Struct, frozen=True, forbid_unknown_fields=True):
+ id: Annotated[int, Meta(ge=1, description="Encoder id. 0 is the NAV encoder, which no config can bind")]
+ type: EncoderType | None | UnsetType = UNSET
+ midi_CC: MidiCC | NoneToken | None | UnsetType = UNSET
+ midi_channel: EncoderMidiChannel = UNSET
+ midi_port: EncoderMidiPort = UNSET
+ longpress: str | None | UnsetType = UNSET
+ disable: bool | None | UnsetType = UNSET
+
+ def __post_init__(self) -> None:
+ _check_routing(self.midi_port, self.midi_channel)
+
+
+class AnalogEntry(Struct, frozen=True, forbid_unknown_fields=True):
+ id: int
+ adc_input: int | None | UnsetType = UNSET
+ type: SwitchType | None | UnsetType = UNSET
+ threshold: Annotated[int, Meta(ge=0, le=127)] | None | UnsetType = UNSET
+ autosync: bool | None | UnsetType = UNSET
+ midi_CC: MidiCC | NoneToken | None | UnsetType = UNSET
+ midi_channel: AnalogMidiChannel = UNSET
+ midi_port: AnalogMidiPort = UNSET
+ disable: bool | None | UnsetType = UNSET
+
+ def __post_init__(self) -> None:
+ _check_routing(self.midi_port, self.midi_channel)
+
+
+class MidiSection(Struct, frozen=True, forbid_unknown_fields=True):
+ channel: FileMidiChannel
+
+
+class ExternalMidiSection(Struct, frozen=True, forbid_unknown_fields=True):
+ enabled: bool | UnsetType = UNSET
+ send_delay_ms: Annotated[int, Meta(ge=0)] | UnsetType = UNSET
+ messages: dict[str, list[list[Annotated[int, Meta(ge=0, le=255)]]]] | UnsetType = UNSET
+
+
+class HardwareSection(Struct, frozen=True, forbid_unknown_fields=True):
+ version: float | UnsetType = UNSET
+ midi: MidiSection | UnsetType = UNSET
+ footswitches: list[FootswitchEntry] | None | UnsetType = UNSET
+ encoders: list[EncoderEntry] | None | UnsetType = UNSET
+ analog_controllers: list[AnalogEntry] | None | UnsetType = UNSET
+ external_midi: ExternalMidiSection | None | UnsetType = UNSET
+
+
+class ConfigDocument(Struct, frozen=True, forbid_unknown_fields=True):
+ hardware: HardwareSection | UnsetType = UNSET
+ blend_snapshots: list[BlendSnapshotConfig] | None | UnsetType = UNSET
+
+
+class MergedDocument(Struct, frozen=True):
+ version: float | UnsetType
+ midi_channel: int | UnsetType
+ external_midi: ExternalMidiSection
+ blend_snapshots: list[BlendSnapshotConfig]
+ footswitches: tuple[FootswitchEntry, ...]
+ encoders: tuple[EncoderEntry, ...]
+ analog_controllers: tuple[AnalogEntry, ...]
+
+
+class ConfigError(Exception):
+ pass
+
+
+_E = TypeVar("_E", FootswitchEntry, EncoderEntry, AnalogEntry)
+_S = TypeVar("_S", bound=Struct)
+
+
+def _set_fields(entry: Struct) -> dict[str, Any]:
+ return {k: v for k, v in asdict(entry).items() if v is not UNSET}
+
+
+def _overlaid(base: _S, over: _S) -> _S:
+ return replace(base, **_set_fields(over))
+
+
+def _by_id(entries: list[_E] | None | UnsetType, section: str) -> dict[int, _E]:
+ if entries is UNSET or entries is None:
+ return {}
+ found: dict[int, _E] = {}
+ for entry in entries:
+ if entry.id in found:
+ logging.warning("config: %s has more than one entry with id %d", section, entry.id)
+ found[entry.id] = entry
+ return found
+
+
+def _merged_entries(
+ base_section: HardwareSection | UnsetType,
+ over_section: HardwareSection | UnsetType,
+ pick: Callable[[HardwareSection], list[_E] | None | UnsetType],
+ name: str,
+) -> tuple[_E, ...]:
+ base_list = pick(base_section) if base_section is not UNSET else UNSET
+ over_list = pick(over_section) if over_section is not UNSET else UNSET
+ if over_list is None:
+ return ()
+ base = _by_id(base_list, name)
+ for control_id, entry in _by_id(over_list, name).items():
+ if control_id not in base:
+ logging.warning("config: %s id %d is not in %s", name, control_id, DEFAULT_CONFIG_FILE)
+ continue
+ base[control_id] = _overlaid(base[control_id], entry)
+ return tuple(base[k] for k in sorted(base))
+
+
+def _hardware(doc: ConfigDocument | None) -> HardwareSection | UnsetType:
+ return doc.hardware if doc is not None else UNSET
+
+
+def _merged_external_midi(base: HardwareSection | UnsetType, over: HardwareSection | UnsetType) -> ExternalMidiSection:
+ if over is not UNSET and over.external_midi is None:
+ return ExternalMidiSection()
+ sections = [s.external_midi for s in (base, over) if s is not UNSET]
+ merged = ExternalMidiSection()
+ for section in sections:
+ if section is UNSET or section is None:
+ continue
+ messages = merged.messages if merged.messages is not UNSET else {}
+ merged = _overlaid(merged, section)
+ if section.messages is not UNSET:
+ merged = replace(merged, messages={**messages, **section.messages})
+ return merged
+
+
+def merge(default: ConfigDocument, pedalboard: ConfigDocument | None = None) -> MergedDocument:
+ """Overlay a pedalboard document onto the global document."""
+ base = _hardware(default)
+ over = _hardware(pedalboard)
+
+ blend: list[BlendSnapshotConfig] | None | UnsetType = UNSET
+ if pedalboard is not None:
+ blend = pedalboard.blend_snapshots
+ if blend is UNSET:
+ blend = default.blend_snapshots
+ if blend is UNSET or blend is None:
+ blend = []
+
+ channel: int | UnsetType = UNSET
+ for section in (base, over):
+ if section is not UNSET and section.midi is not UNSET:
+ channel = section.midi.channel
+
+ return MergedDocument(
+ version=base.version if base is not UNSET else UNSET,
+ midi_channel=channel,
+ external_midi=_merged_external_midi(base, over),
+ blend_snapshots=blend,
+ footswitches=_merged_entries(base, over, lambda s: s.footswitches, "footswitches"),
+ encoders=_merged_entries(base, over, lambda s: s.encoders, "encoders"),
+ analog_controllers=_merged_entries(base, over, lambda s: s.analog_controllers, "analog_controllers"),
+ )
+
+
+def parse(raw: Any, source: str | Path) -> ConfigDocument:
+ """Build a document from already-loaded YAML data."""
+ try:
+ return convert(raw if raw is not None else {}, ConfigDocument)
+ except msgspec.ValidationError as e:
+ raise ConfigError("Config file error in %s: %s" % (source, e)) from None
+
+
+def load_cfg_from_file(path: str | Path) -> ConfigDocument:
+ """Load and validate a config from an explicit file path."""
+ with open(path, "rb") as f:
+ return parse(msgspec.yaml.decode(f.read()), path)
+
+
+def load_default_cfg() -> ConfigDocument:
+ """Load and validate the global default_config.yml."""
+ return load_cfg_from_file(os.path.join(data_dir, DEFAULT_CONFIG_FILE))
+
+
+def read_bundle_config(bundle: str | Path | None) -> ConfigDocument | None:
+ """Read the optional config.yml of a pedalboard bundle.
+
+ A broken pedalboard config is not fatal. The pedalboard then runs on the
+ global defaults alone.
+ """
+ if bundle is None:
+ return None
+ path = Path(bundle) / "config.yml"
+ if not path.exists():
+ return None
+ try:
+ return load_cfg_from_file(path)
+ except (OSError, ConfigError, msgspec.DecodeError):
+ logging.exception("config: ignoring %s", path)
+ return None
+
+
+def hardware_version(document: ConfigDocument) -> float | None:
+ """The hardware version that selects the handler and hardware classes."""
+ if document.hardware is UNSET or document.hardware.version is UNSET:
+ return None
+ return document.hardware.version
+
+
+def json_schema() -> dict[str, Any]:
+ """The JSON Schema of this format, generated from the types above."""
+ return msgspec.json.schema(ConfigDocument)
diff --git a/pistomp/controller.py b/pistomp/controller.py
index 58c9a5ee4..1a2069157 100755
--- a/pistomp/controller.py
+++ b/pistomp/controller.py
@@ -19,14 +19,25 @@
from collections.abc import Callable
from dataclasses import dataclass
-from enum import Enum
+from enum import Enum, StrEnum
from typing import TYPE_CHECKING, TypedDict
+
from common.parameter import Parameter
if TYPE_CHECKING:
from pistomp.input.sink import InputSink
+class ControlType(StrEnum):
+ """What a control does. Only KNOB, EXPRESSION and VOLUME come from a config
+ file; NAV is a fixed property of the hardware."""
+
+ KNOB = "KNOB"
+ EXPRESSION = "EXPRESSION"
+ VOLUME = "VOLUME"
+ NAV = "nav"
+
+
class RoutingDestination(Enum):
VIRTUAL = "virtual"
EXTERNAL = "external"
@@ -47,7 +58,7 @@ def external(cls, port_name: str) -> "RoutingInfo":
class AnalogDisplayInfo(TypedDict, total=False):
- type: str | None # Token.KNOB, Token.EXPRESSION, Token.VOLUME
+ type: ControlType | None # KNOB, EXPRESSION, or VOLUME
id: int | None # Position on screen (0-based from left); None if unpositioned
category: str | None
port_name: str | None # External port name if routed externally
@@ -55,18 +66,19 @@ class AnalogDisplayInfo(TypedDict, total=False):
# Per-pedalboard analog/encoder assignment display, keyed by "instance:param"
-# (plugin-bound), "channel:cc" (external), or Token.VOLUME.
+# (plugin-bound), "channel:cc" (external), or ControlType.VOLUME.
AnalogControllers = dict[str, AnalogDisplayInfo]
class Controller:
- type: str | None = None # class default; not in __init__ — Encoder sets its own type via the encoder MRO
+ type: ControlType | None = None
id: int | None = None # position/identifier for display routing or event filtering
def __init__(self, midi_channel: int, midi_CC: int | None):
self.midi_channel: int = midi_channel
self.midi_CC: int | None = midi_CC
self.parameter: Parameter | None = None
+ self.disabled = False
# type is not declared here — it conflicts with Encoder's MRO.
# Subclasses that carry type must declare it themselves.
self.midi_min: int = 0
diff --git a/pistomp/controller_manager.py b/pistomp/controller_manager.py
index 78f6c45ed..34b6c0256 100644
--- a/pistomp/controller_manager.py
+++ b/pistomp/controller_manager.py
@@ -20,7 +20,6 @@
import logging
from typing import TYPE_CHECKING
-import common.token as Token
from common.contexts import (
BindingDecl,
CallbackEffect,
@@ -32,7 +31,6 @@
ControlRef,
Effect,
EventKind,
- LongpressActionConfig,
MidiCcEffect,
ParamEffect,
PedalboardEffect,
@@ -42,6 +40,13 @@
ShadowState,
TapTempoEffect,
)
+from pistomp.config.model import (
+ ControlType,
+ LongpressAction,
+ LongpressBoard,
+ LongpressMidiCC,
+ LongpressPreset,
+)
from common.parameter import Parameter, PortInfo, Symbol, TTL_INTEGER
from modalapi.external_midi import EXTERNAL_INSTANCE_ID
from pistomp.analogmidicontrol import AnalogMidiControl
@@ -55,53 +60,33 @@
class ControllerManager:
- """
- Manages controller/parameter bindings on the current pedalboard,
- overlaying per-pedalboard config on top of the base.
- The one genuine version difference is passed as a flag rather than subclassed:
-
- reorder_footswitch_plugins v1 moves footswitch-controlled plugins to the
- tail of the chain; v3 leaves order untouched.
- """
+ """Build the runtime bindings of the active pedalboard: the per-pedalboard
+ config over the base config."""
- def __init__(self, hardware: "Hardware", *, reorder_footswitch_plugins: bool = False):
+ def __init__(self, hardware: "Hardware"):
self._hw = hardware
- self._reorder_footswitch_plugins = reorder_footswitch_plugins
- # Effective table (common/contexts.py, pistomp/input/README.md): the
- # PEDALBOARD layer of the resolved binding table, built alongside the
- # legacy dict outputs below. ORPHANED rows record TTL bindings with
- # no matching physical controller, which the legacy path drops
- # silently.
self.effective_table = ContextStack(layers=[])
def bind(self, current: Current | None) -> None:
- """Rebind all controllers for the active pedalboard state."""
+ """Create the runtime associations for the active pedalboard."""
+ self.effective_table = ContextStack(layers=[])
if current is None:
return
- # Clear previous parameter bindings from all controllers except volume.
- for controller in self._hw.controllers.values():
- if controller.type != Token.VOLUME:
- controller.unbind_from_parameter()
-
- current.analog_controllers = {}
- pedalboard_layer = ContextLayer(ref=ContextRef(kind=ContextKind.PEDALBOARD))
+ current.close()
+ layer = ContextLayer(ref=ContextRef(kind=ContextKind.PEDALBOARD))
if current.pedalboard:
- footswitch_plugins = self._bind_plugin_parameters(current, pedalboard_layer)
+ self._bind_plugin_parameters(current, layer)
self._bind_volume_encoders(current)
- if self._reorder_footswitch_plugins:
- self._move_footswitch_plugins_to_end(current, footswitch_plugins)
-
- self._bind_external_controllers(current, pedalboard_layer)
- self._bind_encoder_longpress(pedalboard_layer)
- self._bind_footswitch_actions(pedalboard_layer)
- self.effective_table = ContextStack(layers=[pedalboard_layer])
-
- def _bind_plugin_parameters(self, current, pedalboard_layer: ContextLayer) -> list:
- """Bind controllers referenced by plugin parameters; return the plugins
- that gained a footswitch."""
- footswitch_plugins = []
+
+ self._bind_external_controllers(current, layer)
+ self._bind_encoder_longpress(layer)
+ self._bind_footswitch_actions(layer)
+ self.effective_table = ContextStack(layers=[layer])
+
+ def _bind_plugin_parameters(self, current: Current, pedalboard_layer: ContextLayer) -> None:
+ """Bind controllers referenced by plugin parameters."""
# The transport pseudo-plugin carries :bpm/:bpb/:rolling; it's not in
# pedalboard.plugins (the effect-graph render) but its bindings route
# through the same machinery.
@@ -136,12 +121,12 @@ def _bind_plugin_parameters(self, current, pedalboard_layer: ContextLayer) -> li
)
continue
- controller.bind_to_parameter(param)
+ current.bind(controller, param)
plugin.controllers.append(controller)
+ current.track_plugin_binding(plugin, controller)
if isinstance(controller, Footswitch):
plugin.has_footswitch = True
- footswitch_plugins.append(plugin)
controller.set_category(plugin.category)
event_kind = EventKind.PRESS
cls = ControlClass.FOOTSWITCH
@@ -169,21 +154,15 @@ def _bind_plugin_parameters(self, current, pedalboard_layer: ContextLayer) -> li
enabled_when=enabled_when,
)
)
- return footswitch_plugins
- def _bind_volume_encoders(self, current) -> None:
+ def _bind_volume_encoders(self, current: Current) -> None:
"""Surface VOLUME-type encoders in the assignment display (v3 only in
practice — v1 has no VOLUME-typed encoder)."""
for e in self._hw.encoders:
- if e.type == Token.VOLUME:
- current.analog_controllers[Token.VOLUME] = e.get_display_info()
-
- @staticmethod
- def _move_footswitch_plugins_to_end(current, footswitch_plugins) -> None:
- plugins = current.pedalboard.plugins
- current.pedalboard.plugins = [p for p in plugins if p.has_footswitch is False] + footswitch_plugins
+ if e.type == ControlType.VOLUME:
+ current.analog_controllers[ControlType.VOLUME] = e.get_display_info()
- def _bind_external_controllers(self, current, pedalboard_layer: ContextLayer) -> None:
+ def _bind_external_controllers(self, current: Current, pedalboard_layer: ContextLayer) -> None:
"""Externally-routed controllers: bind a synthetic parameter and show
them under an "External" category."""
for controller in self._hw.controllers.values():
@@ -194,8 +173,11 @@ def _bind_external_controllers(self, current, pedalboard_layer: ContextLayer) ->
if controller.parameter is None:
if isinstance(controller, AnalogMidiControl):
- controller.parameter = self._hw.create_external_parameter(
- port_name, controller.midi_channel, controller.midi_CC, controller.midi_value
+ current.attach(
+ controller,
+ self._hw.create_external_parameter(
+ port_name, controller.midi_channel, controller.midi_CC, controller.midi_value
+ ),
)
else:
ext_info = PortInfo(
@@ -204,8 +186,9 @@ def _bind_external_controllers(self, current, pedalboard_layer: ContextLayer) ->
ranges={"minimum": 0, "maximum": 127},
properties=[TTL_INTEGER],
)
- controller.bind_to_parameter(
- Parameter(ext_info, ENCODER_FALLBACK_DEFAULT, key, EXTERNAL_INSTANCE_ID)
+ current.bind(
+ controller,
+ Parameter(ext_info, ENCODER_FALLBACK_DEFAULT, key, EXTERNAL_INSTANCE_ID),
)
pedalboard_layer.add(
@@ -299,8 +282,7 @@ def _bind_footswitch_actions(self, pedalboard_layer: ContextLayer) -> None:
)
)
- # Mapping-form longpress: dict config, parsed by Footswitch.
- lp = fs.longpress_action
+ lp = self._hw.longpress_action(fs)
if lp is not None:
pedalboard_layer.add(
BindingDecl(
@@ -362,13 +344,11 @@ def _bind_footswitch_actions(self, pedalboard_layer: ContextLayer) -> None:
)
@staticmethod
- def _longpress_action_effects(lp: LongpressActionConfig, fs: Footswitch) -> tuple[Effect, ...]:
- """Translate a mapping-form longpress dict into a single-effect tuple.
- The schema guarantees exactly one key."""
- if "midi_CC" in lp:
- return (RawMidiCcEffect(channel=fs.midi_channel, cc=int(lp["midi_CC"])),)
- if "preset" in lp:
- return (PresetEffect(direction=str(lp["preset"])),)
- if "pedalboard" in lp:
- return (PedalboardEffect(direction=str(lp["pedalboard"])),)
- return ()
+ def _longpress_action_effects(lp: LongpressAction, fs: Footswitch) -> tuple[Effect, ...]:
+ match lp:
+ case LongpressMidiCC():
+ return (RawMidiCcEffect(channel=fs.midi_channel, cc=lp.cc),)
+ case LongpressPreset():
+ return (PresetEffect(direction=str(lp.preset)),)
+ case LongpressBoard():
+ return (PedalboardEffect(direction=lp.direction),)
diff --git a/pistomp/current.py b/pistomp/current.py
index 2b16c4984..db97053c8 100644
--- a/pistomp/current.py
+++ b/pistomp/current.py
@@ -15,19 +15,57 @@
# You should have received a copy of the GNU Affero General Public License
# along with pi-stomp. If not, see .
+
from __future__ import annotations
from dataclasses import dataclass, field
-from pistomp.controller import AnalogControllers
+from common.parameter import Parameter
from modalapi.pedalboard import Pedalboard
+from modalapi.plugin import Plugin
+from pistomp.controller import AnalogControllers, Controller
+from pistomp.footswitch import Footswitch
@dataclass
class Current:
- """Mutable per-pedalboard state for the active ("current") pedalboard."""
+ """The active pedalboard, and the runtime associations that it owns.
+
+ `close` releases the associations. The code that binds must close.
+ """
pedalboard: Pedalboard
presets: dict[int, str] = field(default_factory=dict)
preset_index: int = 0 # Assumes pedalboard loads at snapshot 0 (default behavior)
analog_controllers: AnalogControllers = field(default_factory=dict)
+ _controllers: list[Controller] = field(default_factory=list)
+ _plugin_bindings: list[tuple[Plugin, Controller]] = field(default_factory=list)
+
+ def bind(self, controller: Controller, parameter: Parameter) -> None:
+ controller.bind_to_parameter(parameter)
+ self.track(controller)
+
+ def attach(self, controller: Controller, parameter: Parameter) -> None:
+ controller.parameter = parameter
+ self.track(controller)
+
+ def track(self, controller: Controller) -> None:
+ if controller not in self._controllers:
+ self._controllers.append(controller)
+
+ def track_plugin_binding(self, plugin: Plugin, controller: Controller) -> None:
+ self.track(controller)
+ binding = (plugin, controller)
+ if binding not in self._plugin_bindings:
+ self._plugin_bindings.append(binding)
+
+ def close(self) -> None:
+ for plugin, controller in reversed(self._plugin_bindings):
+ if controller in plugin.controllers:
+ plugin.controllers.remove(controller)
+ plugin.has_footswitch = any(isinstance(c, Footswitch) for c in plugin.controllers)
+ for controller in reversed(self._controllers):
+ controller.unbind_from_parameter()
+ self._plugin_bindings.clear()
+ self._controllers.clear()
+ self.analog_controllers = {}
diff --git a/pistomp/encoder_controller.py b/pistomp/encoder_controller.py
index 5b3b25531..e48b0bb0f 100644
--- a/pistomp/encoder_controller.py
+++ b/pistomp/encoder_controller.py
@@ -26,6 +26,7 @@
import pistomp.adcswitch as adcswitch
import pistomp.gpioswitch as gpioswitch
import pistomp.switchstate as switchstate
+from pistomp.controller import ControlType
from pistomp.encoder import Encoder
from pistomp.input.event import EncoderEvent, SwitchEvent, SwitchEventKind
@@ -77,7 +78,7 @@ def __init__(
*,
midi_channel: int = 0,
midi_CC: Optional[int] = None,
- type: Optional[str] = None,
+ type: ControlType | None = None,
id: Optional[int] = None,
sw_pin: Optional[int] = None,
sw_adc_chan: Optional[int] = None,
diff --git a/pistomp/footswitch.py b/pistomp/footswitch.py
index d4b31c25e..08dcd6be4 100755
--- a/pistomp/footswitch.py
+++ b/pistomp/footswitch.py
@@ -17,17 +17,14 @@
import logging
import sys
-from typing import cast
from typing_extensions import override
-import common.token as Token
import pistomp.controller as controller
import pistomp.adcswitch as adcswitch
import pistomp.gpioswitch as gpioswitch
import pistomp.switchstate as switchstate
from pistomp.input.event import SwitchEvent, SwitchEventKind
-from common.contexts import LongpressActionConfig
from common.parameter import BYPASS_SYMBOL
@@ -58,13 +55,11 @@ def __init__(
self.category = None
self.pixel = pixel
self.longpress_groups: list[str] = []
- # Mapping-form longpress; exclusive with the chord form.
- self.longpress_action: LongpressActionConfig | None = None
self.disabled = False
self.taptempo = taptempo
if adc_input and gpio_input:
- logging.error("Switch cannot be specified with both %s and %s", (Token.ADC_INPUT, Token.GPIO_INPUT))
+ logging.error("Switch %s cannot have both adc_input and gpio_input", id)
sys.exit()
self.gpio_switch = None
@@ -117,6 +112,7 @@ def unbind_from_parameter(self) -> None:
super().unbind_from_parameter()
self.display_label = None
self.set_category(None)
+ self.toggled = False
@property
def press_state(self) -> switchstate.Value:
@@ -194,20 +190,6 @@ def set_category(self, category):
def set_lcd_color(self, color):
self.lcd_color = color
- def set_longpress_groups(self, groups):
- if groups is None:
- self.longpress_groups = []
- self.longpress_action = None
- elif isinstance(groups, str):
- self.longpress_groups = groups.split()
- self.longpress_action = None
- elif isinstance(groups, list):
- self.longpress_groups = groups
- self.longpress_action = None
- elif isinstance(groups, dict):
- self.longpress_groups = []
- self.longpress_action = cast(LongpressActionConfig, groups)
-
def poll(self):
if self.disabled:
return
@@ -238,14 +220,3 @@ def add_preset(self, direction=None, callback_arg=None):
self.preset_direction = direction
self.preset_callback_arg = callback_arg
- def clear_pedalboard_info(self):
- self.toggled = False
- self.disabled = False
- self.display_label = None
- self.set_category(None)
- self.preset_direction = None
- self.preset_callback_arg = None
- self.parameter = None
- self.longpress_groups = []
- self.longpress_action = None
- self.clear_relays()
diff --git a/pistomp/handler.py b/pistomp/handler.py
index d3e2e2bf5..78482d69f 100755
--- a/pistomp/handler.py
+++ b/pistomp/handler.py
@@ -21,11 +21,7 @@
from collections.abc import Callable
from typing import TYPE_CHECKING, Any
-from pistomp.analogmidicontrol import AnalogMidiControl
-from pistomp.controller import Controller
from pistomp.current import Current
-from pistomp.encoder_controller import EncoderController
-from pistomp.footswitch import Footswitch
from pistomp.footswitch_chords import FootswitchChords
from pistomp.input.event import ControllerEvent
from pistomp.input.sink import InputSink
@@ -210,10 +206,10 @@ def hide_fullscreen_panel(self) -> None:
def _apply_midi_binding(
self, instance: str, symbol: Symbol, binding: str, binding_range: tuple[float, float] | None = None
) -> None:
- # A MIDI mapping was learned or cleared in mod-ui. Update the matching
- # parameter's binding and wire/unwire its hardware controller so the LCD
- # reflects it without a pedalboard reload. Idempotent: replayed connect-dump
- # maps are no-ops.
+ # A MIDI mapping was learned or cleared in mod-ui. Record it on the
+ # parameter and re-derive the board: param.binding is the source the
+ # activation builds from, so there is no second wiring path to keep in
+ # step. Idempotent — replayed connect-dump maps are no-ops.
if self._current is None:
return
plugin = self.current.pedalboard.find_plugin(instance)
@@ -222,82 +218,41 @@ def _apply_midi_binding(
param = plugin.parameters.get(symbol)
if param is None:
return
- controller = self.hardware.controllers.get(binding)
+
+ is_unmapped = binding in ("-1:-1", "-1")
+ controller = None if is_unmapped else self.hardware.controllers.get(binding)
+
# The range can change without the binding (re-address the same CC to a
# different sub-range), so apply it before the binding-unchanged bail.
- # On unmap (binding "-1:-1" or "-1"), restore the plugin's declared LV2 range.
- is_unmapped = binding in ("-1:-1", "-1")
- if binding_range is not None and not is_unmapped:
- param.set_binding_range(binding_range)
- elif is_unmapped:
+ # On unmap, restore the plugin's declared LV2 range.
+ if is_unmapped:
param.clear_binding_range()
- if param.binding == binding:
- return
+ elif binding_range is not None:
+ param.set_binding_range(binding_range)
- old_binding = param.binding
- old_controller = self.hardware.controllers.get(old_binding) if old_binding is not None else None
-
- if old_controller is not None and old_binding != binding:
- old_controller.unbind_from_parameter()
- if old_controller in plugin.controllers:
- plugin.controllers.remove(old_controller)
- if isinstance(old_controller, Footswitch):
- plugin.has_footswitch = any(isinstance(c, Footswitch) for c in plugin.controllers)
- elif isinstance(old_controller, (AnalogMidiControl, EncoderController)):
- key = "%s:%s" % (plugin.instance_id, param.name)
- self.current.analog_controllers.pop(key, None)
- # Redraw the displaced controller now or a footswitch stays stale-green
- # on the LCD until the next board load (update_footswitch is single-widget).
- self._redraw_after_binding(old_controller, isinstance(old_controller, Footswitch))
-
- if controller is None:
- param.binding = None
- self._add_learned_binding_row(plugin, param, None, old_binding)
+ # A CC with no physical control is a real external device's mapping: keep
+ # its range, but adopt no binding — there is nothing here to wire.
+ new_binding = binding if controller is not None else None
+ if param.binding == new_binding:
return
# Externally-routed controls aren't bound to plugin parameters; board
# load ignores such bindings (_bind_plugin_parameters) and the live
# learn must agree, or the control's MidiCcEffect row shadows the
# learned row and commits emit raw values out the external port.
- if self.hardware.is_external(controller):
+ if controller is not None and self.hardware.is_external(controller):
logging.warning(
f"MIDI learn for {instance}:{param.name} names external controller "
f"{binding} (routed to {self.hardware.external_port_name(controller)}) - ignoring"
)
return
- param.binding = binding
- is_footswitch = self._bind_controller_to_param(plugin, param, controller)
- self._add_learned_binding_row(plugin, param, controller, old_binding)
- self._redraw_after_binding(controller, is_footswitch)
-
- def _bind_controller_to_param(self, plugin: "Plugin", param: "Parameter", controller: Controller) -> bool:
- # Wire a hardware controller to a plugin parameter. Returns True if the
- # controller is a footswitch (so callers can track footswitch plugins).
- controller.bind_to_parameter(param)
- if controller not in plugin.controllers:
- plugin.controllers.append(controller)
- if isinstance(controller, Footswitch):
- # TODO sort this list so selection orders correctly (sort on midi_CC?)
- plugin.has_footswitch = True
- controller.set_category(plugin.category)
- return True
- elif isinstance(controller, (AnalogMidiControl, EncoderController)):
- key = "%s:%s" % (plugin.instance_id, param.name)
- display_info = controller.get_display_info()
- display_info["category"] = plugin.category
- self.current.analog_controllers[key] = display_info
- return False
+ param.binding = new_binding
+ self._rebind_pedalboard()
- def _redraw_after_binding(self, controller: Controller | None, is_footswitch: bool) -> None:
- # Refresh the LCD after a learned binding. Subclasses redraw at their
- # own granularity.
+ def _rebind_pedalboard(self) -> None:
+ """Build the board's associations and rows again. A handler that owns an
+ activation must override this."""
raise NotImplementedError()
- def _add_learned_binding_row(
- self, plugin: "Plugin", param: "Parameter", controller: Controller | None, old_binding: str | None
- ) -> None:
- # Add a table row for a live-learned binding so dispatch and badges
- # reflect it without a pedalboard reload. MOD subclasses override;
- # non-MOD hosts never receive midi_map.
- pass
+
diff --git a/pistomp/handlerfactory.py b/pistomp/handlerfactory.py
index 855f6fe17..a196f41a9 100644
--- a/pistomp/handlerfactory.py
+++ b/pistomp/handlerfactory.py
@@ -15,8 +15,7 @@
# You should have received a copy of the GNU Affero General Public License
# along with pi-stomp. If not, see .
-import common.token as Token
-import common.util as Util
+import pistomp.config as config
import modalapi.modhandler as Modhandler
@@ -31,10 +30,7 @@ def __init__(self):
def create(self, cfg, audiocard, cwd):
# TODO handler could be independent of hardware (ie, have a software/ui version in the config, etc.)
# to avoid supporting too many hardware/handler combos, we'll keep the handler locked to hardware versioning
- hw = Util.DICT_GET(cfg, Token.HARDWARE)
- if not hw:
- return None
- version = Util.DICT_GET(hw, Token.VERSION)
+ version = config.hardware_version(cfg)
if version is None or version < 2.0 or version >= 4.0:
return None
return Modhandler.Modhandler(audiocard, cwd)
diff --git a/pistomp/hardware.py b/pistomp/hardware.py
index b5af25d73..46a1dd2c2 100755
--- a/pistomp/hardware.py
+++ b/pistomp/hardware.py
@@ -18,25 +18,35 @@
import logging
import os
import sys
+from collections.abc import Callable
+from typing import TypeVar
-import common.token as Token
-import common.util as Util
from common.parameter import Parameter, PortInfo, Symbol, TTL_INTEGER
import pistomp.analogmidicontrol as AnalogMidiControl
+import pistomp.config as config
import pistomp.encoder_controller as EncoderController
import pistomp.footswitch as Footswitch
import pistomp.taptempo as taptempo
from abc import ABC, abstractmethod
-from rtmidi import MidiOut
from modalapi.external_midi import ExternalMidiManager, EXTERNAL_INSTANCE_ID
from pistomp.input.sink import InputSink
-from pistomp.controller import Controller, RoutingInfo, RoutingDestination
+from pistomp.controller import ControlType, Controller, RoutingInfo
+from pistomp.config.model import (
+ AnalogBinding,
+ EncoderBinding,
+ FootswitchBinding,
+ LongpressAction,
+ PedalboardConfig,
+ PresetStep,
+)
+from pistomp.config.schema_v1 import ConfigDocument
import pistomp.relay as Relay
+_Binding = TypeVar("_Binding", FootswitchBinding, EncoderBinding, AnalogBinding)
-class Hardware(ABC):
+class Hardware(ABC):
def __init__(self, default_config, handler, midiout, refresh_callback):
logging.info("Init hardware: " + type(self).__name__)
self.handler = handler
@@ -46,11 +56,9 @@ def __init__(self, default_config, handler, midiout, refresh_callback):
self.test_pass = False
self.test_sentinel = None
- # From config file(s)
- self.default_cfg = default_config
- self.version = self.default_cfg[Token.HARDWARE][Token.VERSION]
- self.cfg = None # compound cfg (default with user/pedalboard specific cfg overlaid)
- self.midi_channel = 0
+ self.default_cfg: ConfigDocument = default_config
+ self.config = config.resolve(default_config)
+ self.base_config = self.config
# Standard hardware objects (not required to exist)
self.relay: Relay.Relay | None = None
@@ -64,10 +72,18 @@ def __init__(self, default_config, handler, midiout, refresh_callback):
self.taptempo = taptempo.TapTempo(None)
self.external_midi: ExternalMidiManager | None = None
# control → destination; absent means internal (virtual/mod-host).
- # Rebuilt every reinit in __apply_midi_routing. Identity-keyed; controllers
- # are stable across reinit (mutated in place).
+ # Rebuilt every reinit. Identity-keyed; controllers are stable across
+ # reinit (mutated in place).
self.external_routing: dict[Controller, RoutingInfo] = {}
+ @property
+ def version(self) -> float:
+ return self.config.version
+
+ @property
+ def midi_channel(self) -> int:
+ return self.config.midi_channel
+
def register_sink(self, sink: InputSink) -> None:
"""Assign `sink` as the default dispatch target for every controller
owned by this hardware. Called by Handler.add_hardware after this
@@ -89,6 +105,7 @@ def toggle_tap_tempo_enable(self, bpm: float = 0.0):
def init_spi(self):
import spidev
+
self.spi = spidev.SpiDev()
self.spi.open(0, 1) # Bus 0, CE1
self.spi.max_speed_hz = 1_000_000
@@ -96,23 +113,31 @@ def init_spi(self):
def poll_controls(self):
# This is intended to be called periodically from main working loop to poll the instantiated controls
for c in self.analog_controls:
- c.refresh()
+ if not c.disabled:
+ c.refresh()
for e in self.encoders:
+ if e.disabled:
+ continue
e.read_rotary()
- if hasattr(e, "poll"):
- e.poll()
+ e.poll()
for s in self.footswitches:
s.poll()
def sync_analog_controls(self):
"""Send current values of analog controls with autosync enabled via MIDI."""
for control in self.analog_controls:
- if isinstance(control, AnalogMidiControl.AnalogMidiControl) and control.autosync:
+ if isinstance(control, AnalogMidiControl.AnalogMidiControl) and not control.disabled and control.autosync:
try:
control.send_current_value()
except Exception as e:
logging.warning(f"Failed to sync analog control {control.midi_CC}: {e}")
+ def longpress_action(self, fs: Footswitch.Footswitch) -> LongpressAction | None:
+ """The mapping form of longpress, which has no home on the footswitch."""
+ binding = self.config.footswitch(fs.id) if fs.id is not None else None
+ spec = binding.longpress if binding is not None else None
+ return None if spec is None or isinstance(spec, tuple) else spec
+
def is_external(self, controller: Controller) -> bool:
return controller in self.external_routing
@@ -132,59 +157,61 @@ def recalibrateVU_baseline(self, baseline):
for i in self.indicators:
i.recalibrate_baseline(baseline)
- def reinit(self, cfg):
- # reinit hardware as specified by the new cfg context (after pedalboard change, etc.)
- self.cfg = self.default_cfg.copy()
- self.external_routing.clear() # rebuilt by __route_section for this cfg overlay
-
- self.__init_midi_default()
-
- # Reset the handler's chord resolver for this pedalboard.
+ def reinit(self, config: PedalboardConfig) -> None:
+ self.config = config
+ self.external_routing.clear()
+ self.controllers.clear()
self.handler.chord_helper.rebuild(self.handler.callbacks)
- # Apply defaults
- self.__init_footswitches(self.cfg)
- self.__init_encoders(self.cfg)
-
- # External MIDI configuration
- self.__init_external_midi(self.cfg)
- self.__apply_midi_routing(self.cfg)
+ if self.external_midi is not None:
+ self.external_midi.set_config(config.external_midi)
- # Pedalboard specific config
- if cfg is not None:
- self.__init_midi(cfg)
- self.__init_footswitches(cfg)
- self.__init_external_midi(cfg)
- self.__init_encoders(cfg)
- self.__apply_midi_routing(cfg)
-
- # Register final longpress-group membership with the chord resolver.
+ # Apply a binding to every control that the base config creates, or the
+ # control keeps the state of the pedalboard before it.
for fs in self.footswitches:
+ binding = self.__binding(config.footswitch, self.base_config.footswitch, fs.id)
+ if binding is not None:
+ self.__apply_footswitch(fs, binding)
self.handler.chord_helper.register(fs)
+ for enc in self.encoders:
+ if enc.type is ControlType.NAV:
+ continue # NAV takes no config; its id is a screen position
+ binding = self.__binding(config.encoder, self.base_config.encoder, enc.id)
+ if binding is not None:
+ self.__apply_encoder(enc, binding)
+
+ for ac in self.analog_controls:
+ binding = self.__binding(config.analog_control, self.base_config.analog_control, ac.id)
+ if binding is not None:
+ self.__apply_analog_control(ac, binding)
+
+ @staticmethod
+ def __binding(
+ current: Callable[[int], _Binding | None], base: Callable[[int], _Binding | None], control_id: int | None
+ ) -> _Binding | None:
+ if control_id is None:
+ return None
+ binding = current(control_id)
+ return binding if binding is not None else base(control_id)
+
@abstractmethod
- def init_analog_controls(self):
- ...
+ def init_analog_controls(self): ...
@abstractmethod
- def init_encoders(self):
- ...
+ def init_encoders(self): ...
@abstractmethod
- def init_footswitches(self):
- ...
+ def init_footswitches(self): ...
@abstractmethod
- def init_relays(self):
- ...
+ def init_relays(self): ...
@abstractmethod
- def cleanup(self):
- ...
+ def cleanup(self): ...
@abstractmethod
- def test(self):
- ...
+ def test(self): ...
def run_test(self):
# if test sentinel file exists execute hardware test
@@ -194,171 +221,108 @@ def run_test(self):
self.test_pass = False
self.test()
- def create_footswitches(self, cfg):
- if cfg is None or (Token.HARDWARE not in cfg) or (Token.FOOTSWITCHES not in cfg[Token.HARDWARE]):
+ def create_footswitches(self, config: PedalboardConfig) -> None:
+ bindings = [b for b in config.footswitches if not b.disable]
+ if not bindings:
return
- cfg_fs = cfg[Token.HARDWARE][Token.FOOTSWITCHES]
- if cfg_fs is None:
- return
-
- # determine if an ledstrip is referenced, if so create an object
- ledstrip_gpio = None
- gpio_output_list = []
- for f in cfg_fs:
- if self.ledstrip is not None and Util.DICT_GET(f, Token.LEDSTRIP_POSITION) is not None:
- ledstrip_gpio = self.ledstrip.get_gpio()
- gpio_output_list.append(Util.DICT_GET(f, Token.GPIO_OUTPUT))
-
- # Must make sure a gpio_output is not specified on the PWM pin used for an ledstring
- if ledstrip_gpio is not None and ledstrip_gpio in gpio_output_list:
- logging.error("Config file error. Cannot have %s on the same GPIO as used for an ledstring referenced by %s"
- % (Token.GPIO_OUTPUT, Token.LEDSTRIP_POSITION))
- sys.exit()
-
- midi_channel = self.get_real_midi_channel(cfg)
- idx = 0
- for f in cfg_fs:
- if Util.DICT_GET(f, Token.DISABLE) is True:
+ uses_ledstrip = self.ledstrip is not None and any(b.ledstrip_position is not None for b in bindings)
+ if uses_ledstrip:
+ assert self.ledstrip is not None
+ ledstrip_gpio = self.ledstrip.get_gpio()
+ if ledstrip_gpio in [b.gpio_output for b in bindings]:
+ logging.error(
+ "Config file error. A gpio_output cannot use the GPIO of the ledstrip at ledstrip_position"
+ )
+ sys.exit()
+
+ for b in bindings:
+ gpio_input = b.gpio_input
+ if self.debounce_map and b.debounce_input in self.debounce_map:
+ gpio_input = self.debounce_map[b.debounce_input]
+
+ if b.adc_input is None and gpio_input is None:
+ logging.error("Config file error. Footswitch %d has no adc_input, gpio_input or debounce_input", b.id)
continue
- di = Util.DICT_GET(f, Token.DEBOUNCE_INPUT)
- if self.debounce_map and di in self.debounce_map:
- gpio_input = self.debounce_map[di]
- else:
- gpio_input = Util.DICT_GET(f, Token.GPIO_INPUT)
-
- adc_input = Util.DICT_GET(f, Token.ADC_INPUT)
- gpio_output = Util.DICT_GET(f, Token.GPIO_OUTPUT)
- tap_tempo_callback = Util.DICT_GET(f, Token.TAP_TEMPO)
- midi_cc = Util.DICT_GET(f, Token.MIDI_CC)
- id = Util.DICT_GET(f, Token.ID)
- led_position = Util.DICT_GET(f, Token.LEDSTRIP_POSITION)
-
pixel = None
- if self.ledstrip and led_position is not None:
- pixel = self.ledstrip.add_pixel(id if id else idx, led_position)
-
- # Create the footswitch object
- if adc_input is None and gpio_input is None:
- logging.error("Config file error. Footswitch specified without %s or %s or %s" %
- (Token.DEBOUNCE_INPUT, Token.GPIO_INPUT, Token.ADC_INPUT))
- continue
-
- taptempo = (self.taptempo if tap_tempo_callback else None)
- if taptempo:
- taptempo.set_callback(self.handler.get_callback(tap_tempo_callback))
-
- fs: Footswitch.Footswitch | None = None
- if adc_input is not None:
- fs = Footswitch.Footswitch(id if id else idx, gpio_output, pixel, midi_cc, midi_channel,
- refresh_callback=self.refresh_callback,
- adc_input=adc_input, spi=self.spi,
- taptempo = taptempo)
- logging.debug("Created Footswitch on ADC input: %d, Midi Chan: %d, CC: %s" %
- (adc_input, midi_channel, midi_cc))
- elif gpio_input is not None:
- fs = Footswitch.Footswitch(id if id else idx, gpio_output, pixel, midi_cc, midi_channel,
- refresh_callback=self.refresh_callback,
- gpio_input=gpio_input,
- taptempo = taptempo)
- logging.debug("Created Footswitch on GPIO input: %d, Midi Chan: %d, CC: %s" %
- (gpio_input, midi_channel, midi_cc))
-
- assert fs is not None, "No footswitch created for config: %s" % f
+ if self.ledstrip is not None and b.ledstrip_position is not None:
+ pixel = self.ledstrip.add_pixel(b.id, b.ledstrip_position)
+
+ switch_taptempo = None
+ if b.tap_tempo is not None:
+ switch_taptempo = self.taptempo
+ switch_taptempo.set_callback(self.handler.get_callback(b.tap_tempo))
+
+ if b.adc_input is not None:
+ fs = Footswitch.Footswitch(
+ b.id,
+ b.gpio_output,
+ pixel,
+ b.midi_CC,
+ b.midi_channel,
+ refresh_callback=self.refresh_callback,
+ adc_input=b.adc_input,
+ spi=self.spi,
+ taptempo=switch_taptempo,
+ )
+ else:
+ fs = Footswitch.Footswitch(
+ b.id,
+ b.gpio_output,
+ pixel,
+ b.midi_CC,
+ b.midi_channel,
+ refresh_callback=self.refresh_callback,
+ gpio_input=gpio_input,
+ taptempo=switch_taptempo,
+ )
+ logging.debug("Created Footswitch %d, Midi Chan: %d, CC: %s", b.id, b.midi_channel, b.midi_CC)
self.footswitches.append(fs)
- idx += 1
-
- def create_analog_controls(self, cfg):
- if cfg is None or (Token.HARDWARE not in cfg) or (Token.ANALOG_CONTROLLERS not in cfg[Token.HARDWARE]):
- return
+ self.register_controller(fs)
- midi_channel = self.get_real_midi_channel(cfg)
- cfg_c = cfg[Token.HARDWARE][Token.ANALOG_CONTROLLERS]
- if cfg_c is None:
- return
- for c in cfg_c:
- if Util.DICT_GET(c, Token.DISABLE) is True:
+ def create_analog_controls(self, config: PedalboardConfig) -> None:
+ for b in config.analog_controls:
+ if b.disable:
continue
-
- id = Util.DICT_GET(c, Token.ID)
- adc_input = Util.DICT_GET(c, Token.ADC_INPUT)
- midi_cc = Util.DICT_GET(c, Token.MIDI_CC)
- threshold = Util.DICT_GET(c, Token.THRESHOLD)
- control_type = Util.DICT_GET(c, Token.TYPE)
- autosync = Util.DICT_GET(c, Token.AUTOSYNC)
-
- if adc_input is None:
- logging.error("Config file error. Analog control specified without %s" % Token.ADC_INPUT)
+ if b.adc_input is None:
+ logging.error("Config file error. Analog control %d has no adc_input", b.id)
continue
- if midi_cc is None:
- logging.error("Config file error. Analog control specified without %s" % Token.MIDI_CC)
+ if b.midi_CC is None:
+ logging.error("Config file error. Analog control %d has no midi_CC", b.id)
continue
- if threshold is None:
- threshold = 16 # Default, 1024 is full scale
- if autosync is None:
- autosync = False # Default to False
- control = AnalogMidiControl.AnalogMidiControl(self.spi, adc_input, threshold, midi_cc, midi_channel,
- control_type, id, c, autosync)
+ control = AnalogMidiControl.AnalogMidiControl(
+ self.spi, b.adc_input, b.threshold, b.midi_CC, b.midi_channel, b.type, b.id, b.autosync
+ )
self.analog_controls.append(control)
- key = format("%d:%d" % (midi_channel, midi_cc))
- self.controllers[key] = control
- logging.debug("Created AnalogMidiControl Input: %d, Midi Chan: %d, CC: %d" %
- (adc_input, midi_channel, midi_cc))
+ self.register_controller(control)
+ logging.debug(
+ "Created AnalogMidiControl Input: %d, Midi Chan: %d, CC: %d", b.adc_input, b.midi_channel, b.midi_CC
+ )
@abstractmethod
- def add_encoder(self, id, type, longpress_callback, midi_channel, midi_cc) -> EncoderController.EncoderController | None:
+ def add_encoder(
+ self, id, type, longpress_callback, midi_channel, midi_cc
+ ) -> EncoderController.EncoderController | None:
# This should be implemented by hardware subclasses that support tweak encoders (Tre at least)
...
- def create_encoders(self, cfg):
- if cfg is None or (Token.HARDWARE not in cfg) or (Token.ENCODERS not in cfg[Token.HARDWARE]):
- return
-
- midi_channel = self.get_real_midi_channel(cfg)
- cfg_c = cfg[Token.HARDWARE][Token.ENCODERS]
- if cfg_c is None:
- return
- for c in cfg_c:
- if Util.DICT_GET(c, Token.DISABLE) is True:
+ def create_encoders(self, config: PedalboardConfig) -> None:
+ for b in config.encoders:
+ if b.disable:
continue
-
- id = Util.DICT_GET(c, Token.ID)
- type = Util.DICT_GET(c, Token.TYPE)
- midi_cc = Util.DICT_GET(c, Token.MIDI_CC)
- longpress_callback = Util.DICT_GET(c, Token.LONGPRESS)
-
- if id is None:
- logging.error("Config file error. Encoder specified without %s" % Token.ID)
- continue
-
- # midi_port routing is applied later in __apply_midi_routing (external_midi is None here)
try:
- control = self.add_encoder(id, type, longpress_callback, midi_channel, midi_cc)
- # FIXME: add_encoder returns None for emulator v1/v2 stubs that don't
- # implement config-driven encoders, forcing the return type to be optional.
- if control is not None:
- self.encoders.append(control)
+ control = self.add_encoder(b.id, b.type, b.longpress, b.midi_channel, b.midi_CC)
except Exception:
- logging.exception("Failed to create encoder with config: %s" % c)
+ logging.exception("Failed to create encoder %d", b.id)
continue
-
- if midi_cc is not None:
- assert isinstance(control, EncoderController.EncoderController), "Encoder specified with MIDI CC must be an EncoderController"
- key = format("%d:%d" % (midi_channel, midi_cc))
- self.controllers[key] = control
- logging.debug("Created Encoder: %d, Midi Chan: %d, CC: %d" % (id, midi_channel, midi_cc))
-
- def get_real_midi_channel(self, cfg):
- chan = 0
- try:
- val = cfg[Token.HARDWARE][Token.MIDI][Token.CHANNEL]
- # LAME bug in Mod detects MIDI channel as one higher than sent (7 sent, seen by mod as 8) so compensate here
- chan = val - 1 if val > 0 else 0
- except KeyError:
- pass
- return chan
+ # FIXME: add_encoder returns None for emulator v1/v2 stubs that don't
+ # implement config-driven encoders, forcing the return type to be optional.
+ if control is not None:
+ self.encoders.append(control)
+ self.register_controller(control)
+ logging.debug("Created Encoder: %d, Midi Chan: %d, CC: %s", b.id, b.midi_channel, b.midi_CC)
def create_external_parameter(self, port_name, midi_channel, midi_cc, initial_value: int = 0):
name = f"{port_name}:{midi_cc}"
@@ -376,172 +340,66 @@ def __validate_midi_port(self, port_name):
return None
return port_name
- def __resolve_midiout(self, cfg_entry) -> tuple[MidiOut, RoutingInfo]:
- """Return (midiout, routing): always the virtual MidiOut; routing tells _emit_midi where to go."""
- midi_port = Util.DICT_GET(cfg_entry, Token.MIDI_PORT)
- if midi_port:
- midi_port = self.__validate_midi_port(midi_port)
- if not midi_port or self.external_midi is None:
- return self.midiout, RoutingInfo.virtual()
- self.external_midi.open_port(midi_port) # eager: first poll-loop send must not enumerate
- return self.midiout, RoutingInfo.external(midi_port)
-
- def __route_section(self, cfg, section, controls, set_cc):
- cfg_list = Util.DICT_GET(cfg[Token.HARDWARE], section)
- if not cfg_list:
+ def register_controller(self, control: Controller) -> None:
+ if control.disabled or control.midi_CC is None:
return
- for entry in cfg_list:
- ctrl_id = Util.DICT_GET(entry, Token.ID)
- if ctrl_id is None:
- continue
- ctrl = next((c for c in controls if getattr(c, 'id', None) == ctrl_id), None)
- if ctrl is None:
- continue
- # Footswitch midi_CC (incl. NONE removal) is owned by __init_footswitches; only encoders/analog here.
- if set_cc:
- midi_cc = Util.DICT_GET(entry, Token.MIDI_CC)
- if midi_cc is not None and hasattr(ctrl, 'midi_CC'):
- ctrl.midi_CC = midi_cc
- midi_port = Util.DICT_GET(entry, Token.MIDI_PORT)
- midi_channel = Util.DICT_GET(entry, Token.MIDI_CHANNEL)
- if midi_port and midi_channel is None:
- logging.error("Config file error: %s id=%s sets midi_port '%s' without midi_channel; "
- "external devices rarely share the hardware default channel" %
- (section, ctrl_id, midi_port))
- if midi_channel is not None:
- ctrl.midi_channel = midi_channel
- _, routing = self.__resolve_midiout(entry)
- if routing.destination == RoutingDestination.EXTERNAL:
- self.external_routing[ctrl] = routing
- else:
- self.external_routing.pop(ctrl, None)
+ self.controllers["%d:%d" % (control.midi_channel, control.midi_CC)] = control
- def __apply_midi_routing(self, cfg):
- """Route every control to its external port or the virtual port (default + pedalboard cfg)."""
- if cfg is None or Token.HARDWARE not in cfg:
+ def __route(self, control: Controller, midi_port: str | None) -> None:
+ port = self.__validate_midi_port(midi_port) if midi_port else None
+ if port is None or self.external_midi is None:
+ self.external_routing.pop(control, None)
return
- self.__route_section(cfg, Token.ENCODERS, self.encoders, set_cc=True)
- self.__route_section(cfg, Token.ANALOG_CONTROLLERS, self.analog_controls, set_cc=True)
- self.__route_section(cfg, Token.FOOTSWITCHES, self.footswitches, set_cc=False)
-
- def __init_midi_default(self):
- self.__init_midi(self.cfg)
-
- def __init_midi(self, cfg):
- self.midi_channel = self.get_real_midi_channel(cfg)
- # TODO could iterate thru all objects here instead of handling in __init_footswitches
- for ac in self.analog_controls:
- if isinstance(ac, AnalogMidiControl.AnalogMidiControl):
- ac.set_midi_channel(self.midi_channel)
+ self.external_midi.open_port(port)
+ self.external_routing[control] = RoutingInfo.external(port)
+
+ def __apply_footswitch(self, fs: Footswitch.Footswitch, binding: FootswitchBinding) -> None:
+ fs.toggled = False
+ fs.disabled = binding.disable
+ fs.set_display_label(None)
+ fs.set_category(None)
+ fs.clear_relays()
+ fs.add_preset(direction=None, callback_arg=None)
+ fs.set_lcd_color(binding.color)
+ spec = binding.longpress
+ fs.longpress_groups = list(spec) if isinstance(spec, tuple) else []
+ fs.set_midi_channel(binding.midi_channel)
+ fs.set_midi_CC(binding.midi_CC)
+
+ if binding.uses_relay:
+ if self.relay is not None:
+ fs.add_relay(self.relay)
+ fs.set_display_label("byps")
+ else:
+ logging.warning("Footswitch %s bypass config ignored, no relay hardware", binding.id)
- def __init_external_midi(self, cfg):
- """Initialize/update external MIDI config (called for both default and pedalboard)."""
- if self.external_midi is None:
- return
- if cfg is None or Token.HARDWARE not in cfg:
- return
- ext_cfg = cfg[Token.HARDWARE].get("external_midi")
- if ext_cfg:
- self.external_midi.update_config(ext_cfg)
-
- def __clear_footswitch_midi_cc(self, fs) -> None:
- fs.set_midi_CC(None)
- for k, v in self.controllers.items():
- if v == fs:
- self.controllers.pop(k)
- break
-
- def __init_footswitches(self, cfg):
- if cfg is None or (Token.HARDWARE not in cfg) or (Token.FOOTSWITCHES not in cfg[Token.HARDWARE]):
- return
- cfg_fs = cfg[Token.HARDWARE][Token.FOOTSWITCHES]
- idx = 0
- fs = None
- for fs in self.footswitches:
- # See if a corresponding cfg entry exists. if so, override
- f = None
- for f in cfg_fs:
- if f[Token.ID] == idx:
- break
- else:
- f = None
-
- if f is not None:
- # TODO reusing the footswitch object for multiple pedalboards is not ideal
- # could easily have spillover from a previous pedalboard
- # The mutable data should probably be stored in a separate object and destructed/constructed upon
- # each pedalboard load
- fs.clear_pedalboard_info()
-
- # Bypass
- if Token.BYPASS in f:
- # TODO no more right or left
- if f[Token.BYPASS] == Token.LEFT_RIGHT or f[Token.BYPASS] == Token.LEFT:
- if self.relay is not None:
- fs.add_relay(self.relay)
- fs.set_display_label("byps")
- else:
- logging.warning(
- "Footswitch %d bypass config ignored — no relay hardware (v3)",
- idx,
- )
-
- # Midi
- if Token.MIDI_CC in f:
- cc = f[Token.MIDI_CC]
- if cc == Token.NONE:
- self.__clear_footswitch_midi_cc(fs)
- else:
- fs.set_midi_channel(self.midi_channel)
- fs.set_midi_CC(cc)
- key = format("%d:%d" % (self.midi_channel, fs.midi_CC))
- self.controllers[key] = fs # TODO problem if this creates a new element?
-
- # Clearing midi_CC drops the fs from hw.controllers, so an
- # unrelated plugin's MIDI-learned :bypass can't bind onto it;
- # dispatch_key falls back to "fs:" and the rows still resolve.
- if Token.PRESET in f:
- self.__clear_footswitch_midi_cc(fs)
- preset_value = f[Token.PRESET]
- if preset_value == Token.UP:
- fs.add_preset(direction="UP")
- fs.set_display_label("Pre+")
- elif preset_value == Token.DOWN:
- fs.add_preset(direction="DOWN")
- fs.set_display_label("Pre-")
- elif isinstance(preset_value, int):
- fs.add_preset(direction=str(preset_value), callback_arg=preset_value)
- fs.set_display_label(str(preset_value))
-
- # Suppress (per-pedalboard disable without removing the object)
- if Util.DICT_GET(f, Token.DISABLE) is True:
- fs.disabled = True
- idx += 1
- continue
-
- # LCD/LED attributes
- if Token.COLOR in f:
- fs.set_lcd_color(f[Token.COLOR])
-
- # Longpress and longpress groups
- if Token.LONGPRESS in f: # Can be a list or a single (string)
- fs.set_longpress_groups(Util.DICT_GET(f, Token.LONGPRESS))
-
- idx += 1
-
- def __init_encoders(self, cfg: dict | None) -> None:
- if cfg is None or Token.HARDWARE not in cfg:
- return
- cfg_encs = Util.DICT_GET(cfg[Token.HARDWARE], Token.ENCODERS)
- if not cfg_encs:
- return
- for enc_cfg in cfg_encs:
- enc_id = Util.DICT_GET(enc_cfg, Token.ID)
- if enc_id is None:
- continue
- enc = next((e for e in self.encoders if getattr(e, "id", None) == enc_id), None)
- if enc is None or not hasattr(enc, "set_longpress"):
- continue
- if Token.LONGPRESS in enc_cfg:
- lp_name = enc_cfg[Token.LONGPRESS]
- enc.set_longpress(lp_name or None)
+ if binding.preset is not None:
+ fs.set_midi_CC(None)
+ if isinstance(binding.preset, PresetStep):
+ fs.add_preset(direction=binding.preset.value)
+ fs.set_display_label("Pre+" if binding.preset is PresetStep.UP else "Pre-")
+ else:
+ fs.add_preset(direction=str(binding.preset), callback_arg=binding.preset)
+ fs.set_display_label(str(binding.preset))
+
+ self.register_controller(fs)
+ self.__route(fs, binding.midi_port)
+
+ def __apply_encoder(self, enc: Controller, binding: EncoderBinding) -> None:
+ enc.type = binding.type
+ enc.disabled = binding.disable
+ enc.midi_channel = binding.midi_channel
+ enc.midi_CC = binding.midi_CC
+ if isinstance(enc, EncoderController.EncoderController):
+ enc.set_longpress(binding.longpress)
+ self.register_controller(enc)
+ self.__route(enc, binding.midi_port)
+
+ def __apply_analog_control(self, control: Controller, binding: AnalogBinding) -> None:
+ control.disabled = binding.disable
+ control.midi_channel = binding.midi_channel
+ control.midi_CC = binding.midi_CC
+ if isinstance(control, AnalogMidiControl.AnalogMidiControl):
+ control.autosync = binding.autosync
+ self.register_controller(control)
+ self.__route(control, binding.midi_port)
diff --git a/pistomp/hardwarefactory.py b/pistomp/hardwarefactory.py
index 3f22c4611..73d18afe5 100644
--- a/pistomp/hardwarefactory.py
+++ b/pistomp/hardwarefactory.py
@@ -15,8 +15,7 @@
# You should have received a copy of the GNU Affero General Public License
# along with pi-stomp. If not, see .
-import common.token as Token
-import common.util as Util
+import pistomp.config as config
import pistomp.pistompcore as Pistompcore
import pistomp.pistomptre as Pistomptre
@@ -31,10 +30,7 @@ def __init__(self):
Hardwarefactory.__single = self
def create(self, cfg, handler, midiout):
- hw = Util.DICT_GET(cfg, Token.HARDWARE)
- if not hw:
- return None
- version = Util.DICT_GET(hw, Token.VERSION)
+ version = config.hardware_version(cfg)
if version is None:
return None
if (version >= 2.0) and (version < 3.0):
diff --git a/pistomp/lcd320x240.py b/pistomp/lcd320x240.py
index 0e69d2976..cc1f9b709 100644
--- a/pistomp/lcd320x240.py
+++ b/pistomp/lcd320x240.py
@@ -24,6 +24,7 @@
from typing import TYPE_CHECKING, Optional
from common.fonts import font_path
import common.token as Token
+from pistomp.controller import ControlType
import common.util as util
from common.contexts import BindingDecl, ControlClass, EventKind, MidiCcEffect, ParamEffect, ShadowState
from common.parameter import BYPASS_SYMBOL, Parameter, PortInfo, Symbol, Type
@@ -735,7 +736,7 @@ def _encoder_badge_for_control_id(self, control_id: str) -> int | None:
controller = self.handler.hardware.controllers.get(control_id)
if (
isinstance(controller, EncoderController)
- and controller.type not in (Token.NAV, Token.VOLUME)
+ and controller.type not in (ControlType.NAV, ControlType.VOLUME)
and controller.id is not None
):
return controller.id
@@ -1230,7 +1231,7 @@ def draw_analog_assignments(self, controllers):
# Look up the actual control instance for progress bar tracking
analog_control = None
for ac in self.handler.hardware.analog_controls + self.handler.hardware.encoders:
- if hasattr(ac, "id") and ac.id == i and getattr(ac, "type", None) != Token.NAV:
+ if ac.id == i and ac.type != ControlType.NAV:
analog_control = ac
break
@@ -1246,8 +1247,8 @@ def draw_analog_assignments(self, controllers):
if k is None:
# Non-mapped control
name = "none"
- control_type = Token.EXPRESSION if i == 0 else Token.KNOB # HACK cuz we don't know type of unmapped
- subtitle = "Expression pedal (unassigned)" if control_type == Token.EXPRESSION else "Knob (unassigned)"
+ control_type = ControlType.EXPRESSION if i == 0 else ControlType.KNOB # HACK cuz we don't know type of unmapped
+ subtitle = "Expression pedal (unassigned)" if control_type == ControlType.EXPRESSION else "Knob (unassigned)"
color = accent_color_for(None)
text_color = color
control_label_fn = None
@@ -1257,10 +1258,10 @@ def draw_analog_assignments(self, controllers):
control_type = util.DICT_GET(v, Token.TYPE)
control_label_fn = None
control_param = None
- if control_type == Token.VOLUME:
+ if control_type == ControlType.VOLUME:
name = "volume"
subtitle = "Output volume"
- control_type = Token.KNOB
+ control_type = ControlType.KNOB
color = TILE_DEFAULT_COLOR
text_color = color
else:
@@ -1305,7 +1306,7 @@ def draw_analog_assignments(self, controllers):
subtitle = f"Blend: {snapshot_name}"
w = None
- if control_type == Token.KNOB:
+ if control_type == ControlType.KNOB:
w = Icon(
box=Box.xywh(x, y, TILE_W, height_per_control),
text=name,
@@ -1320,7 +1321,7 @@ def draw_analog_assignments(self, controllers):
if blend_initial_progress is not None:
w.set_progress(blend_initial_progress)
self.w_controls.append(w)
- elif control_type == Token.EXPRESSION:
+ elif control_type == ControlType.EXPRESSION:
w = Icon(
box=Box.xywh(x, y, TILE_W, height_per_control),
text=name,
@@ -1343,7 +1344,7 @@ def draw_analog_assignments(self, controllers):
# Rebuild path: widget create/destroy above marks regions dirty, but
# the LCD push only fires on a refresh. Called standalone from
- # _redraw_after_binding (midi-learn of an encoder), where there's no
+ # _rebind_pedalboard (midi-learn of an encoder), where there's no
# enclosing draw_main_panel to refresh for us.
self.main_panel.refresh()
diff --git a/pistomp/pistompcore.py b/pistomp/pistompcore.py
index d1e6f1f2f..0a8eeca4c 100755
--- a/pistomp/pistompcore.py
+++ b/pistomp/pistompcore.py
@@ -23,7 +23,7 @@
#
# A new version with different controls should have a new separate subclass
-import common.token as Token
+from pistomp.controller import ControlType
import pistomp.encoder_controller as EncoderController
import pistomp.hardware as hardware
import pistomp.relay as Relay
@@ -80,7 +80,7 @@ def init_encoders(self):
top_enc = EncoderController.EncoderController(
TOP_ENC_PIN_D,
TOP_ENC_PIN_CLK,
- type=Token.NAV,
+ type=ControlType.NAV,
sw_pin=1,
)
self.encoders.append(top_enc)
@@ -91,14 +91,12 @@ def init_relays(self):
self.relay.init_state()
def init_analog_controls(self):
- cfg = self.default_cfg.copy()
if len(self.analog_controls) == 0:
- self.create_analog_controls(cfg)
+ self.create_analog_controls(self.config)
def init_footswitches(self):
- cfg = self.default_cfg.copy()
if len(self.footswitches) == 0:
- self.create_footswitches(cfg)
+ self.create_footswitches(self.config)
def cleanup(self):
pass
diff --git a/pistomp/pistomptre.py b/pistomp/pistomptre.py
index 4f97328a9..0c1995735 100755
--- a/pistomp/pistomptre.py
+++ b/pistomp/pistomptre.py
@@ -18,7 +18,7 @@
import logging
import pistomp.analogVU as AnalogVU
-import common.token as Token
+from pistomp.controller import ControlType
import common.util as Util
import pistomp.encoder_controller as EncoderController
import pistomp.hardware as hardware
@@ -101,10 +101,10 @@ def add_encoder(self, id, type, longpress_callback, midi_channel, midi_cc):
sw_pin = Util.DICT_GET(enc_pins, 'SW')
# Volume encoders have no MIDI CC; tweak encoders are KNOB-typed.
- if type == Token.VOLUME:
+ if type == ControlType.VOLUME:
enc_type, enc_cc = type, None
else:
- enc_type, enc_cc = Token.KNOB, midi_cc
+ enc_type, enc_cc = ControlType.KNOB, midi_cc
return EncoderController.EncoderController(
d_pin=d_pin, clk_pin=clk_pin,
@@ -116,29 +116,26 @@ def add_encoder(self, id, type, longpress_callback, midi_channel, midi_cc):
def init_encoders(self):
enc = EncoderController.EncoderController(
- NAV_PIN_D, NAV_PIN_CLK, type=Token.NAV,
+ NAV_PIN_D, NAV_PIN_CLK, type=ControlType.NAV,
sw_adc_chan=NAV_ADC_CHAN, spi=self.spi,
max_drain=1, # one detent per tick → visible selector scanning
)
self.encoders.append(enc)
# Tweak encoders
- cfg = self.default_cfg.copy()
- self.create_encoders(cfg)
+ self.create_encoders(self.config)
def init_relays(self):
pass
def init_analog_controls(self):
# These are defined in the config file
- cfg = self.default_cfg.copy()
if len(self.analog_controls) == 0:
- self.create_analog_controls(cfg)
+ self.create_analog_controls(self.config)
def init_footswitches(self):
# These are defined in the config file
- cfg = self.default_cfg.copy()
if len(self.footswitches) == 0:
- self.create_footswitches(cfg)
+ self.create_footswitches(self.config)
def init_vu(self):
if self.ledstrip is None:
diff --git a/plugins/base.py b/plugins/base.py
index d762764cb..5e5d3d5ff 100644
--- a/plugins/base.py
+++ b/plugins/base.py
@@ -63,7 +63,7 @@
from common.parameter import BYPASS_SYMBOL, Parameter, Symbol
from common.parameter_steps import ParameterSteps, effective_multiplier
from modalapi.plugin import Plugin
-import common.token as Token
+from pistomp.controller import ControlType
from pistomp.input.dispatch import MultiSelectable, Selectable, fire, resolve_local
from pistomp.input.event import ControllerEvent, EncoderEvent, SwitchEvent, SwitchEventKind
from pistomp.handler import Handler
@@ -183,7 +183,7 @@ def on_event(self, event: ControllerEvent) -> bool:
# open the encoders belong to the panel, so swallow it.
if (isinstance(event, SwitchEvent)
and event.kind is SwitchEventKind.LONGPRESS
- and event.controller.type in (Token.KNOB, Token.VOLUME)):
+ and event.controller.type in (ControlType.KNOB, ControlType.VOLUME)):
return True
if not isinstance(event, EncoderEvent):
return False
diff --git a/pyproject.toml b/pyproject.toml
index a0a73ccb3..3fd8eaee8 100644
--- a/pyproject.toml
+++ b/pyproject.toml
@@ -18,7 +18,6 @@ classifiers = [
dependencies = [
"pyyaml>=6.0",
- "jsonschema>=4.0",
"JACK-Client>=0.5.5",
"python-rtmidi>=1.4",
"Pillow>=9.4",
@@ -29,6 +28,7 @@ dependencies = [
"gpiozero>=2.0; sys_platform == 'linux'",
"pygame-ce>=2.5.7",
"qrcode>=8.0",
+ "msgspec>=0.21.1",
]
[project.optional-dependencies]
@@ -56,6 +56,7 @@ dev = [
"pytest-cov>=7.0.0",
"ruff>=0.1.0",
"typing-extensions>=4.15.0",
+ "jsonschema>=4.0",
]
[project.urls]
diff --git a/setup/config_templates/default_config.yml b/setup/config_templates/default_config.yml
index b36112b3e..8d74eca76 100755
--- a/setup/config_templates/default_config.yml
+++ b/setup/config_templates/default_config.yml
@@ -74,7 +74,8 @@ hardware:
# encoders:
# Each encoder definition is a list which starts with the id
- # id: The encoder id and position on the screen (required)
+ # id: The encoder id and position on the screen (required, 1 or more;
+ # 0 is the NAV encoder, which no config can bind)
# type: The control type (default is KNOB, VOLUME controls output volume)
# midi_CC: The MIDI CC message to be sent when the control is adjusted (optional)
# cannot be used along with type=VOLUME
diff --git a/setup/config_templates/default_config_pistomptre.yml b/setup/config_templates/default_config_pistomptre.yml
index 0014ca673..60b6303b8 100644
--- a/setup/config_templates/default_config_pistomptre.yml
+++ b/setup/config_templates/default_config_pistomptre.yml
@@ -74,7 +74,8 @@ hardware:
# encoders:
# Each encoder definition is a list which starts with the id
- # id: The encoder id and position on the screen (required)
+ # id: The encoder id and position on the screen (required, 1 or more;
+ # 0 is the NAV encoder, which no config can bind)
# type: The control type (default is KNOB, VOLUME controls output volume)
# midi_CC: The MIDI CC message to be sent when the control is adjusted (optional)
# cannot be used along with type=VOLUME
diff --git a/tests/integration/conftest.py b/tests/integration/conftest.py
index 36b6fc5df..3d2cb5444 100644
--- a/tests/integration/conftest.py
+++ b/tests/integration/conftest.py
@@ -10,7 +10,7 @@
from unittest.mock import patch, MagicMock
import pytest
-import yaml
+from pistomp.config import load_cfg_from_file
from tests.conftest import FakeWebSocketBridge
from tests.types import CapturedLcd, SystemFixture
@@ -47,8 +47,7 @@ def _build_stack(
singleton_attr = f"_{hw_class.__name__}__single"
setattr(hw_class, singleton_attr, None)
- with open(cfg_path) as f:
- cfg = yaml.safe_load(f)
+ cfg = load_cfg_from_file(cfg_path)
fake_bridge = FakeWebSocketBridge()
diff --git a/tests/snapshots/v3/test_midi_learn/test_v3_midi_unlearn_footswitch_clears_binding/unbound.png b/tests/snapshots/v3/test_midi_learn/test_v3_midi_unlearn_footswitch_clears_binding/unbound.png
index 0132255fa..ec5009b59 100644
Binary files a/tests/snapshots/v3/test_midi_learn/test_v3_midi_unlearn_footswitch_clears_binding/unbound.png and b/tests/snapshots/v3/test_midi_learn/test_v3_midi_unlearn_footswitch_clears_binding/unbound.png differ
diff --git a/tests/test_config_schema.py b/tests/test_config_schema.py
index 12c567dd7..7c6a477d1 100644
--- a/tests/test_config_schema.py
+++ b/tests/test_config_schema.py
@@ -1,34 +1,35 @@
-"""Schema regression net for pistomp/config.py (S1: external_midi / midi_port).
+"""Regression net for the version 1 config format.
-Guards two things: every shipped template stays schema-valid, and the
-external-MIDI routing surface (per-control `midi_port` + the `external_midi`
-block) is both accepted when well-formed and rejected when malformed.
+Guards two things: every shipped template still parses, and the external-MIDI
+routing surface (per-control `midi_port` plus the `external_midi` block) is
+accepted when well-formed and rejected when malformed.
"""
import glob
import pytest
-import yaml
-from jsonschema import Draft4Validator, exceptions, validate
+from jsonschema import Draft202012Validator
-from pistomp.config import schema
+from pistomp.config import ConfigError, json_schema, load_cfg_from_file, parse
TEMPLATES = sorted(glob.glob("setup/config_templates/default_config*.yml"))
-def test_schema_is_well_formed():
- Draft4Validator.check_schema(schema)
+def _parse(cfg):
+ return parse(cfg, "")
+
+
+def test_generated_schema_is_well_formed():
+ Draft202012Validator.check_schema(json_schema())
@pytest.mark.parametrize("path", TEMPLATES, ids=lambda p: p.rsplit("/", 1)[-1])
-def test_shipped_template_validates(path):
- with open(path) as fh:
- cfg = yaml.safe_load(fh)
- validate(instance=cfg, schema=schema)
+def test_shipped_template_parses(path):
+ load_cfg_from_file(path)
def test_midi_port_and_external_midi_accepted():
- cfg = {
+ _parse({
"hardware": {
"version": 3.0,
"midi": {"channel": 14},
@@ -51,20 +52,34 @@ def test_midi_port_and_external_midi_accepted():
},
},
}
- }
- validate(instance=cfg, schema=schema)
+ })
def test_non_string_midi_port_rejected():
- cfg = {
- "hardware": {
- "version": 3.0,
- "midi": {"channel": 14},
- "encoders": [{"id": 1, "midi_port": 5, "midi_channel": 0}],
- }
- }
- with pytest.raises(exceptions.ValidationError):
- validate(instance=cfg, schema=schema)
+ with pytest.raises(ConfigError):
+ _parse({"hardware": {"encoders": [{"id": 1, "midi_port": 5, "midi_channel": 0}]}})
+
+
+def test_unknown_key_rejected():
+ with pytest.raises(ConfigError):
+ _parse({"hardware": {"footswitches": [{"id": 0, "colour": "Red"}]}})
+
+
+def test_entry_without_id_rejected():
+ with pytest.raises(ConfigError):
+ _parse({"hardware": {"footswitches": [{"midi_CC": 60}]}})
+
+
+def test_encoder_id_zero_rejected():
+ """Encoder 0 is the NAV encoder. No config may bind it."""
+ with pytest.raises(ConfigError):
+ _parse({"hardware": {"encoders": [{"id": 0, "midi_CC": 70}]}})
+ _parse({"hardware": {"encoders": [{"id": 1, "midi_CC": 70}]}})
+
+
+def test_midi_cc_out_of_range_rejected():
+ with pytest.raises(ConfigError):
+ _parse({"hardware": {"footswitches": [{"id": 0, "midi_CC": 200}]}})
@pytest.mark.parametrize("section,entry", [
@@ -73,15 +88,8 @@ def test_non_string_midi_port_rejected():
("encoders", {"id": 1, "midi_CC": 70, "midi_port": "Source Audio C4 Synth"}),
])
def test_midi_port_without_midi_channel_rejected(section, entry):
- cfg = {
- "hardware": {
- "version": 3.0,
- "midi": {"channel": 14},
- section: [entry],
- }
- }
- with pytest.raises(exceptions.ValidationError):
- validate(instance=cfg, schema=schema)
+ with pytest.raises(ConfigError):
+ _parse({"hardware": {section: [entry]}})
@pytest.mark.parametrize("section,entry", [
@@ -90,30 +98,12 @@ def test_midi_port_without_midi_channel_rejected(section, entry):
("encoders", {"id": 1, "midi_CC": 70}),
])
def test_midi_channel_not_required_without_midi_port(section, entry):
- """midi_channel is only required alongside midi_port; the common no-external-routing case must stay untouched."""
- cfg = {
- "hardware": {
- "version": 3.0,
- "midi": {"channel": 14},
- section: [entry],
- }
- }
- validate(instance=cfg, schema=schema)
-
-
-# ---------------------------------------------------------------------------
-# Mapping-form longpress (PLAN.md)
-# ---------------------------------------------------------------------------
+ """midi_channel is only needed with midi_port; the common case stays untouched."""
+ _parse({"hardware": {section: [entry]}})
def _fs_cfg(longpress):
- return {
- "hardware": {
- "version": 3.0,
- "midi": {"channel": 14},
- "footswitches": [{"id": 0, "midi_CC": 60, "longpress": longpress}],
- }
- }
+ return {"hardware": {"footswitches": [{"id": 0, "midi_CC": 60, "longpress": longpress}]}}
@pytest.mark.parametrize("longpress", [
@@ -127,17 +117,56 @@ def _fs_cfg(longpress):
"next_pedalboard",
"previous_pedalboard",
["previous_snapshot", "previous_pedalboard"],
+ None,
])
-def test_longpress_mapping_form_valid(longpress):
- validate(instance=_fs_cfg(longpress), schema=schema)
+def test_longpress_form_valid(longpress):
+ _parse(_fs_cfg(longpress))
@pytest.mark.parametrize("longpress", [
{"midi_CC": "foo"},
- {"midi_CC": 64, "preset": "UP"}, # mutually exclusive
+ {"midi_CC": 64, "preset": "UP"},
+ {},
{"pedalboard": "SIDEWAYS"},
{"bogus": 1},
+ "not_a_handler_name",
+ ["next_snapshot", "not_a_handler_name"],
])
-def test_longpress_mapping_form_invalid(longpress):
- with pytest.raises(exceptions.ValidationError):
- validate(instance=_fs_cfg(longpress), schema=schema)
+def test_longpress_form_invalid(longpress):
+ with pytest.raises(ConfigError):
+ _parse(_fs_cfg(longpress))
+
+
+def test_explicit_null_clears_overlay_sections():
+ from pistomp.config.adapt_v1 import adapt
+ from pistomp.config.schema_v1 import merge
+
+ base = _parse({
+ "hardware": {
+ "version": 3.0,
+ "midi": {"channel": 14},
+ "footswitches": [{"id": 0, "adc_input": 0, "midi_CC": 60}],
+ "encoders": [{"id": 1, "midi_CC": 70}],
+ "analog_controllers": [{"id": 2, "adc_input": 0, "midi_CC": 75}],
+ "external_midi": {"enabled": True, "messages": {"dev": [[0xC0, 1]]}},
+ },
+ "blend_snapshots": [{"name": "Blend", "input_id": 0, "stops": [0, 1]}],
+ })
+ overlay = _parse({
+ "hardware": {
+ "footswitches": None,
+ "encoders": None,
+ "analog_controllers": None,
+ "external_midi": None,
+ },
+ "blend_snapshots": None,
+ })
+
+ effective = adapt(merge(base, overlay))
+
+ assert effective.footswitches == ()
+ assert effective.encoders == ()
+ assert effective.analog_controls == ()
+ assert effective.external_midi.get("enabled") is False
+ assert effective.external_midi.get("messages") == {}
+ assert effective.blend_snapshots == ()
diff --git a/tests/test_controller_manager.py b/tests/test_controller_manager.py
index 541eaa48f..7d9e9a03e 100644
--- a/tests/test_controller_manager.py
+++ b/tests/test_controller_manager.py
@@ -3,11 +3,12 @@
from typing import cast
from unittest.mock import MagicMock
-import common.token as Token
+from pistomp.controller import ControlType
from common.contexts import ControlClass, EventKind, MidiCcEffect, ShadowState
from common.parameter import Parameter, PortInfo, Symbol
from modalapi.plugin import Plugin
from pistomp.analogmidicontrol import AnalogMidiControl
+from pistomp.controller import Controller
from pistomp.controller_manager import ControllerManager
from pistomp.current import Current
@@ -34,16 +35,15 @@ def unbind_from_parameter(self) -> None:
def test_bind_preserves_volume_binding_clears_others():
- """Controller.type is a class-level default, so the volume guard is type-safe:
- bind() clears every controller's parameter except the VOLUME control's."""
- vol = _Ctl(Token.VOLUME)
- knob = _Ctl(Token.KNOB)
+ vol = _Ctl(ControlType.VOLUME)
+ knob = _Ctl(ControlType.KNOB)
+ current = _make_current()
+ current.track(cast(Controller, knob))
hw = MagicMock()
hw.controllers = {"0:7": vol, "0:8": knob}
hw.encoders = []
hw.is_external.return_value = False
- current = _make_current()
ControllerManager(hw).bind(current)
assert vol.parameter == "bound"
@@ -51,7 +51,7 @@ def test_bind_preserves_volume_binding_clears_others():
def _external_analog(midi_cc=75, midi_channel=0, ctrl_id=3):
- return AnalogMidiControl(MagicMock(), 0, 16, midi_cc, midi_channel, Token.KNOB, id=ctrl_id, cfg={})
+ return AnalogMidiControl(MagicMock(), 0, 16, midi_cc, midi_channel, ControlType.KNOB, id=ctrl_id)
def test_external_controller_bound_and_displayed():
diff --git a/tests/test_failfast_startup.py b/tests/test_failfast_startup.py
index 993a8c733..0c7172417 100644
--- a/tests/test_failfast_startup.py
+++ b/tests/test_failfast_startup.py
@@ -8,6 +8,7 @@
import common.token as Token
from modalapi.pedalboard_monitor import write_last_json
+from pistomp.config import parse
with patch("pistomp.settings.Settings.load_settings"), patch("pistomp.settings.Settings.set_setting"):
from modalapi.modhandler import Modhandler, STARTUP_REST_BACKOFF_S
@@ -16,6 +17,12 @@
PROJECT_ROOT = Path(__file__).parent.parent
+def _mock_hardware():
+ hw = MagicMock()
+ hw.default_cfg = parse({}, "")
+ return hw
+
+
def _data_dir(tmp_path: Path) -> Path:
data_dir = tmp_path / "data"
data_dir.mkdir()
@@ -84,7 +91,7 @@ def get_side_effect(url, **kwargs):
handler = Modhandler(MagicMock(), str(PROJECT_ROOT), data_dir=str(data_dir))
handler.settings = MagicMock()
handler.settings.get_setting.return_value = None
- handler.add_hardware(MagicMock())
+ handler.add_hardware(_mock_hardware())
handler.add_lcd(MagicMock())
handler.load_pedalboards()
diff --git a/tests/test_footswitch.py b/tests/test_footswitch.py
index a5a9b7733..6fe6b57e3 100644
--- a/tests/test_footswitch.py
+++ b/tests/test_footswitch.py
@@ -41,24 +41,6 @@ def _make_footswitch(**kwargs):
yield fs, sink
-class TestLongpressGroups:
- def test_set_longpress_groups_stores_list(self):
- with _make_footswitch() as (fs, _sink):
- fs.set_longpress_groups(["next_snapshot"])
- assert fs.longpress_groups == ["next_snapshot"]
-
- def test_set_longpress_groups_accepts_space_separated_string(self):
- with _make_footswitch() as (fs, _sink):
- fs.set_longpress_groups("next_snapshot toggle_bypass")
- assert fs.longpress_groups == ["next_snapshot", "toggle_bypass"]
-
- def test_set_longpress_groups_none_clears(self):
- with _make_footswitch() as (fs, _sink):
- fs.set_longpress_groups(["toggle_bypass"])
- fs.set_longpress_groups(None)
- assert fs.longpress_groups == []
-
-
class TestOnSwitch:
def test_short_press_dispatches_press_event(self):
with _make_footswitch() as (fs, sink):
@@ -175,45 +157,3 @@ def test_no_parameter_uses_bypass_logic(self):
assert fs.toggled is True
fs.set_value(1)
assert fs.toggled is False
-
-
-class TestClearPedalboardInfo:
- def test_clears_state(self):
- with _make_footswitch() as (fs, _sink):
- fs.toggled = True
- fs.display_label = "Reverb"
- pixel = MagicMock()
- fs.pixel = pixel
-
- fs.clear_pedalboard_info()
-
- assert fs.toggled is False
- assert fs.display_label is None
- assert fs.preset_direction is None
-
- def test_clears_preset_callback_arg(self):
- """Regression: clear_pedalboard_info must also reset preset_callback_arg.
- get_display_label() short-circuits to "" only when both midi_CC and
- preset_callback_arg are None, so a stale callback_arg makes the footswitch
- keep acting like a preset switch and fall through to the else branch that
- returns the (now-None) display_label — i.e. the old preset binding bleeds
- onto the new pedalboard."""
- with _make_footswitch(midi_CC=None) as (fs, _sink):
- fs.add_preset(callback_arg=5)
- fs.set_display_label("Lead")
-
- fs.clear_pedalboard_info()
-
- assert fs.preset_callback_arg is None
- assert fs.get_display_label() == ""
-
- def test_clears_parameter(self):
- """Regression: clear_pedalboard_info must also reset parameter, so the
- drives_display check (and any other consumer of fs.parameter) doesn't
- see a stale plugin binding from a previous pedalboard."""
- with _make_footswitch(midi_CC=None) as (fs, _sink):
- fs.parameter = TestSetValue._param(BYPASS_SYMBOL, 0)
-
- fs.clear_pedalboard_info()
-
- assert fs.parameter is None
diff --git a/tests/test_hardware.py b/tests/test_hardware.py
index 502f630ef..9e558710d 100644
--- a/tests/test_hardware.py
+++ b/tests/test_hardware.py
@@ -1,14 +1,18 @@
"""Unit tests for pistomp.hardware.Hardware helpers."""
import logging
-from typing import cast
from unittest.mock import MagicMock
import pytest
-import common.token as Token
+from pistomp.controller import ControlType
from modalapi.external_midi import ExternalMidiManager
+from pistomp.analogmidicontrol import AnalogMidiControl
+from pistomp.encoder_controller import EncoderController
+from pistomp.footswitch import Footswitch
from pistomp.hardware import Hardware
+from pistomp.config.adapt_v1 import adapt
+from pistomp.config.schema_v1 import merge, parse
class _Ctl:
@@ -19,7 +23,7 @@ def __init__(self, **kw):
class _StubHardware(Hardware):
- """Concrete subclass so object.__new__ works (Hardware is abstract)."""
+ """Concrete subclass; the hardware init hooks do nothing."""
def init_analog_controls(self): ...
def init_encoders(self): ...
@@ -56,31 +60,48 @@ def test_uninitialized_external_midi_logs_warning_not_error(self, caplog):
@pytest.fixture
def routed_hw(monkeypatch):
- """A Hardware with one encoder, analog control, and footswitch, and a 'c4' external port."""
+ """A Hardware with one encoder, analog control, and footswitch, and a 'My MIDI Device' external port."""
mock_out = MagicMock()
mock_out.get_ports.return_value = ["My MIDI Device"]
monkeypatch.setattr("modalapi.external_midi.rtmidi.MidiOut", lambda *a, **k: mock_out)
- hw = object.__new__(_StubHardware)
- hw.midiout = MagicMock(name="virtual")
+ hw = _StubHardware(
+ parse(DEFAULT_CFG, ""), handler=MagicMock(), midiout=MagicMock(name="virtual"),
+ refresh_callback=lambda **k: None,
+ )
hw.external_midi = ExternalMidiManager()
hw.external_midi.update_config({"enabled": True})
- hw.encoders = [_Ctl(id=1, midi_CC=70, midi_channel=13)]
- hw.analog_controls = cast(list, [_Ctl(id=2, midi_CC=75)])
- hw.footswitches = cast(list, [_Ctl(id=0)])
- hw.external_routing = {} # __new__ bypasses __init__; __route_section writes here
+ hw.encoders = [EncoderController(d_pin=None, clk_pin=None, midi_CC=70, midi_channel=13, id=1)]
+ hw.analog_controls = [AnalogMidiControl(None, 0, 16, 75, 13, ControlType.KNOB, id=2)]
+ hw.footswitches = [Footswitch(0, None, None, 60, 13, refresh_callback=lambda **k: None)]
return hw
+DEFAULT_CFG = {
+ "hardware": {
+ "version": 3.0,
+ "midi": {"channel": 14},
+ "footswitches": [{"id": 0, "adc_input": 0, "midi_CC": 60}],
+ "encoders": [{"id": 1, "midi_CC": 70}],
+ "analog_controllers": [{"id": 2, "adc_input": 5, "midi_CC": 75}],
+ }
+}
+
+
+def _resolved(pedalboard_cfg=None):
+ overlay = parse(pedalboard_cfg, "") if pedalboard_cfg is not None else None
+ return adapt(merge(parse(DEFAULT_CFG, ""), overlay))
+
+
def _route(hw, cfg):
- hw._Hardware__apply_midi_routing(cfg)
+ hw.reinit(_resolved(cfg))
class TestApplyMidiRouting:
def test_footswitch_routed_to_external_port(self, routed_hw):
"""A footswitch with midi_port routes to its external port."""
- cfg = {Token.HARDWARE: {Token.FOOTSWITCHES: [{Token.ID: 0, "midi_port": "My MIDI Device"}]}}
+ cfg = {"hardware": {"footswitches": [{"id": 0, "midi_port": "My MIDI Device", "midi_channel": 3}]}}
_route(routed_hw, cfg)
fs = routed_hw.footswitches[0]
assert routed_hw.is_external(fs)
@@ -89,27 +110,25 @@ def test_footswitch_routed_to_external_port(self, routed_hw):
def test_unrouted_control_is_internal(self, routed_hw):
"""No midi_port → internal: absent from the registry, sends to virtual."""
- cfg = {Token.HARDWARE: {Token.FOOTSWITCHES: [{Token.ID: 0}]}}
- _route(routed_hw, cfg)
+ _route(routed_hw, {"hardware": {"footswitches": [{"id": 0}]}})
fs = routed_hw.footswitches[0]
assert not routed_hw.is_external(fs)
assert routed_hw.external_port_name(fs) is None
assert fs not in routed_hw.external_routing
def test_routing_overlay_clears_external(self, routed_hw):
- """A later cfg pass with no midi_port removes a prior external routing."""
- ext = {Token.HARDWARE: {Token.FOOTSWITCHES: [{Token.ID: 0, "midi_port": "My MIDI Device"}]}}
- _route(routed_hw, ext)
+ """A later pedalboard with no midi_port removes a prior external routing."""
+ _route(routed_hw, {"hardware": {"footswitches": [{"id": 0, "midi_port": "My MIDI Device", "midi_channel": 3}]}})
fs = routed_hw.footswitches[0]
assert routed_hw.is_external(fs)
- _route(routed_hw, {Token.HARDWARE: {Token.FOOTSWITCHES: [{Token.ID: 0}]}})
+ _route(routed_hw, {"hardware": {"footswitches": [{"id": 0}]}})
assert not routed_hw.is_external(fs)
def test_encoder_and_analog_routed_to_external_port(self, routed_hw):
cfg = {
- Token.HARDWARE: {
- Token.ENCODERS: [{Token.ID: 1, "midi_port": "My MIDI Device"}],
- Token.ANALOG_CONTROLLERS: [{Token.ID: 2, "midi_port": "My MIDI Device"}],
+ "hardware": {
+ "encoders": [{"id": 1, "midi_port": "My MIDI Device", "midi_channel": 3}],
+ "analog_controllers": [{"id": 2, "midi_port": "My MIDI Device", "midi_channel": 3}],
}
}
_route(routed_hw, cfg)
@@ -119,47 +138,38 @@ def test_encoder_and_analog_routed_to_external_port(self, routed_hw):
assert routed_hw.external_port_name(routed_hw.analog_controls[0]) == "My MIDI Device"
def test_encoder_midi_cc_override(self, routed_hw):
- cfg = {Token.HARDWARE: {Token.ENCODERS: [{Token.ID: 1, Token.MIDI_CC: 99}]}}
- _route(routed_hw, cfg)
+ _route(routed_hw, {"hardware": {"encoders": [{"id": 1, "midi_CC": 99}]}})
assert routed_hw.encoders[0].midi_CC == 99
def test_encoder_midi_channel_override(self, routed_hw):
"""External device may be on a different channel than the hardware default."""
- cfg = {Token.HARDWARE: {Token.ENCODERS: [{Token.ID: 1, "midi_channel": 0}]}}
- _route(routed_hw, cfg)
+ _route(routed_hw, {"hardware": {"encoders": [{"id": 1, "midi_channel": 0}]}})
assert routed_hw.encoders[0].midi_channel == 0
- def test_no_midi_port_falls_back_to_virtual(self, routed_hw):
- cfg = {Token.HARDWARE: {Token.FOOTSWITCHES: [{Token.ID: 0}]}}
- _route(routed_hw, cfg)
- assert not routed_hw.is_external(routed_hw.footswitches[0])
-
def test_external_port_opened_eagerly(self, routed_hw):
"""The external port is opened at routing time, not lazily inside the poll loop."""
- cfg = {Token.HARDWARE: {Token.FOOTSWITCHES: [{Token.ID: 0, "midi_port": "My MIDI Device"}]}}
- _route(routed_hw, cfg)
+ _route(routed_hw, {"hardware": {"footswitches": [{"id": 0, "midi_port": "My MIDI Device", "midi_channel": 3}]}})
assert "My MIDI Device" in routed_hw.external_midi.midi_ports
+ def test_default_config_routing_applies_without_a_pedalboard(self, routed_hw):
+ """Routing comes from default_config.yml too, not only a pedalboard overlay."""
+ default = {
+ "hardware": {
+ **DEFAULT_CFG["hardware"],
+ "footswitches": [
+ {"id": 0, "adc_input": 0, "midi_CC": 60,
+ "midi_port": "My MIDI Device", "midi_channel": 3}
+ ],
+ }
+ }
+ routed_hw.reinit(adapt(merge(parse(default, ""))))
+ assert routed_hw.is_external(routed_hw.footswitches[0])
+
-class TestReinitDefaultRouting:
- def test_reinit_applies_routing_for_default_cfg(self, monkeypatch):
- """Routing is applied for the default config, not only for pedalboard cfg."""
- hw = object.__new__(_StubHardware)
- hw.default_cfg = {Token.HARDWARE: {}}
- hw.handler = MagicMock()
- hw.footswitches = [] # reinit registers longpress groups over these
- hw.external_routing = {} # __new__ bypasses __init__; reinit clears it
-
- for name in (
- "_Hardware__init_midi_default",
- "_Hardware__init_footswitches",
- "_Hardware__init_encoders",
- "_Hardware__init_external_midi",
- ):
- setattr(hw, name, lambda *a, **k: None)
- routed = []
- setattr(hw, "_Hardware__apply_midi_routing", lambda cfg: routed.append(cfg))
-
- hw.reinit(None)
-
- assert routed == [hw.cfg]
+def test_analog_disable_removes_controller(routed_hw):
+ analog = routed_hw.analog_controls[0]
+ cfg = {"hardware": {"analog_controllers": [{"id": 2, "disable": True}]}}
+
+ _route(routed_hw, cfg)
+
+ assert all(controller is not analog for controller in routed_hw.controllers.values())
diff --git a/tests/test_lcd320x240.py b/tests/test_lcd320x240.py
index 49b115a2c..06bf68a57 100644
--- a/tests/test_lcd320x240.py
+++ b/tests/test_lcd320x240.py
@@ -29,6 +29,7 @@
from pistomp.lcd320x240 import Lcd
from pistomp.taptempo import TapTempo
import common.token as Token
+from pistomp.controller import ControlType
from uilib.misc import InputEvent
from modalapi.connections import Connection, Endpoint, EndpointKind
from modalapi.pedalboard import Pedalboard
@@ -158,7 +159,7 @@ def setup_main_ui(instance):
presets={0: "Clean", 1: "Lead"},
preset_index=0,
analog_controllers={
- "exp:pedal": {Token.ID: 0, Token.TYPE: Token.EXPRESSION},
+ "exp:pedal": {Token.ID: 0, Token.TYPE: ControlType.EXPRESSION},
},
)
mock_footswitches = [_make_footswitch(i) for i in range(4)]
@@ -187,9 +188,9 @@ def test_analog_assignments_snapshot(lcd, snapshot):
presets={0: "Clean"},
preset_index=0,
analog_controllers={
- "exp:pedal": {Token.ID: 0, Token.TYPE: Token.EXPRESSION},
- "gain:knob": {Token.ID: 1, Token.TYPE: Token.KNOB},
- "vol:knob": {Token.ID: 2, Token.TYPE: Token.VOLUME},
+ "exp:pedal": {Token.ID: 0, Token.TYPE: ControlType.EXPRESSION},
+ "gain:knob": {Token.ID: 1, Token.TYPE: ControlType.KNOB},
+ "vol:knob": {Token.ID: 2, Token.TYPE: ControlType.VOLUME},
},
)
instance.link_data(pedalboards=[mock_pedalboard], current=mock_current, footswitches=[])
@@ -373,7 +374,7 @@ def test_tweak_button_click_closes_parameter_dialog(lcd):
assert d.parent is not None # open
knob = Controller(midi_channel=0, midi_CC=None)
- knob.type = Token.KNOB
+ knob.type = ControlType.KNOB
knob.id = 1
instance.handle(SwitchEvent(controller=knob, kind=SwitchEventKind.PRESS, timestamp=0.0))
assert d.parent is None # closed by the tweak-button click
@@ -596,7 +597,7 @@ def test_parameter_dialog_shows_tweak_badge_for_external_param(lcd):
}
ext_param = Parameter(ext_info, 0, "0:71", EXTERNAL_INSTANCE_ID)
- enc = EncoderController(d_pin=None, clk_pin=None, type=Token.KNOB, id=2, midi_channel=0, midi_CC=71)
+ enc = EncoderController(d_pin=None, clk_pin=None, type=ControlType.KNOB, id=2, midi_channel=0, midi_CC=71)
enc.bind_to_parameter(ext_param)
instance.handler.hardware.controllers = {"0:71": enc}
instance.handler.effective_table = ContextStack(
diff --git a/tests/v3/bind_helpers.py b/tests/v3/bind_helpers.py
new file mode 100644
index 000000000..274dc6dff
--- /dev/null
+++ b/tests/v3/bind_helpers.py
@@ -0,0 +1,14 @@
+"""Bind a footswitch to a plugin the way a pedalboard load does: record the
+binding on the parameter, then build the board again."""
+
+from common.parameter import BYPASS_SYMBOL
+from modalapi.plugin import Plugin
+from pistomp.footswitch import Footswitch
+from tests.types import SystemFixture
+
+
+def bind_bypass(v3_system: SystemFixture, plugin: Plugin, fs: Footswitch) -> None:
+ assert fs.midi_CC is not None, "test needs a footswitch with a midi_CC"
+ plugin.parameters[BYPASS_SYMBOL].binding = "%d:%d" % (fs.midi_channel, fs.midi_CC)
+ v3_system.handler.current.pedalboard.plugins = [plugin]
+ v3_system.handler._rebind_pedalboard()
diff --git a/tests/v3/conftest.py b/tests/v3/conftest.py
index f3e77852c..c59df59fd 100644
--- a/tests/v3/conftest.py
+++ b/tests/v3/conftest.py
@@ -10,6 +10,7 @@
import yaml
import common.token as Token
+from pistomp.controller import ControlType
from emulator.controls import MockAnalogControl
from modalapi.wifi import SavedConnection, ScannedNetwork
from tests.conftest import FakeWebSocketBridge
@@ -265,7 +266,7 @@ def _add_exp_pedal(hw):
midi_CC=75,
midi_channel=0,
midiout=None,
- control_type=Token.EXPRESSION,
+ control_type=ControlType.EXPRESSION,
id=0,
)
exp_pedal.last_read = 512
diff --git a/tests/v3/nav_helpers.py b/tests/v3/nav_helpers.py
index e412451f7..f6e0df61b 100644
--- a/tests/v3/nav_helpers.py
+++ b/tests/v3/nav_helpers.py
@@ -4,13 +4,13 @@
import time
-import common.token as Token
+from pistomp.controller import ControlType
from pistomp.input.event import EncoderEvent, SwitchEvent, SwitchEventKind
def nav_encoder(handler):
for e in handler.hardware.encoders:
- if e.type == Token.NAV:
+ if e.type == ControlType.NAV:
return e
raise AssertionError("handler has no NAV encoder")
diff --git a/tests/v3/test_audio_midi_panel.py b/tests/v3/test_audio_midi_panel.py
index 90fffab35..32195b0da 100644
--- a/tests/v3/test_audio_midi_panel.py
+++ b/tests/v3/test_audio_midi_panel.py
@@ -182,13 +182,13 @@ def test_tweak1_edits_selected_eq_band(self, audio_midi_system: SystemFixture):
def test_tweak2_edits_input_gain(self, audio_midi_system: SystemFixture):
from pistomp.encoder_controller import EncoderController
from pistomp.input.event import EncoderEvent
- import common.token as Token
+ from pistomp.controller import ControlType
handler = audio_midi_system.handler
_open_panel(audio_midi_system)
enc = MagicMock(spec=EncoderController)
enc.id = 2
- enc.type = Token.KNOB
+ enc.type = ControlType.KNOB
enc.midi_CC = None
ev = EncoderEvent(controller=enc, rotations=2)
handler.handle(ev)
diff --git a/tests/v3/test_encoder_dispatch.py b/tests/v3/test_encoder_dispatch.py
index 11b881264..88f160ec3 100644
--- a/tests/v3/test_encoder_dispatch.py
+++ b/tests/v3/test_encoder_dispatch.py
@@ -18,7 +18,7 @@
from typing import cast
from unittest.mock import MagicMock
-import common.token as Token
+from pistomp.controller import ControlType
from common.contexts import (
BindingDecl,
ContextKind,
@@ -63,13 +63,13 @@ def test_v3_encoder_id_type_mapping(v3_system: SystemFixture):
hw = v3_system.hw
by_id = {getattr(e, "id", None): e for e in hw.encoders}
- assert by_id[1].type == Token.KNOB
+ assert by_id[1].type == ControlType.KNOB
assert by_id[1].midi_CC == 70
- assert by_id[2].type == Token.KNOB
+ assert by_id[2].type == ControlType.KNOB
assert by_id[2].midi_CC == 71
- assert by_id[3].type == Token.VOLUME
+ assert by_id[3].type == ControlType.VOLUME
- nav = next(e for e in hw.encoders if e.type == Token.NAV)
+ nav = next(e for e in hw.encoders if e.type == ControlType.NAV)
assert nav.id is None
diff --git a/tests/v3/test_footswitch_bar.py b/tests/v3/test_footswitch_bar.py
index 9284abd89..beeb8c8c7 100644
--- a/tests/v3/test_footswitch_bar.py
+++ b/tests/v3/test_footswitch_bar.py
@@ -4,24 +4,33 @@
from __future__ import annotations
+import msgspec
import yaml
from uilib.footswitch import FootswitchBarPanel
from uilib.text import TextWidget
-from ui.footswitch_menu import MINUS, _label_for_mapping, _partition_rows, _rows_from_entries
+import pistomp.config as config
+from pistomp.config.adapt_v1 import _footswitch
+from pistomp.config.model import MINUS, LongpressBoard, LongpressMidiCC, LongpressPreset, PresetStep
+from pistomp.config.schema_v1 import FootswitchEntry
+from ui.footswitch_menu import _partition_rows, _rows_from_bindings
from tests.types import SystemFixture
from tests.v3.nav_helpers import nav_click, nav_step
-def test_rows_from_entries_chords_and_solo():
+def _bindings(entries):
+ return [_footswitch(msgspec.convert(e, FootswitchEntry), midi_channel=0) for e in entries]
+
+
+def test_rows_from_bindings_chords_and_solo():
id_to_letter = {0: "A", 1: "B", 2: "C", 3: "D"}
entries = [
{"id": 0, "longpress": "toggle_bypass"},
- {"id": 1, "longpress": "toggle_bypass"}, # chords with id 0 (shared name)
- {"id": 2, "longpress": "toggle_tuner_enable"}, # solo
- {"id": 3, "longpress": {"pedalboard": "DOWN"}}, # mapping-form: never chords
+ {"id": 1, "longpress": "toggle_bypass"},
+ {"id": 2, "longpress": "toggle_tuner_enable"},
+ {"id": 3, "longpress": {"pedalboard": "DOWN"}},
]
- rows = _rows_from_entries(entries, id_to_letter)
+ rows = _rows_from_bindings(_bindings(entries), id_to_letter)
assert rows == [
("A+B", "Toggle Bypass"),
("C", "Tuner"),
@@ -29,18 +38,27 @@ def test_rows_from_entries_chords_and_solo():
]
-def test_rows_from_entries_skips_entries_without_longpress():
+def test_rows_from_bindings_multi_action_longpress():
+ """The adapter turns both file spellings — one name, or a list — into a tuple."""
+ entries = [{"id": 0, "longpress": ["toggle_bypass", "toggle_tuner_enable"]}, {"id": 1, "longpress": "toggle_bypass"}]
+ assert _rows_from_bindings(_bindings(entries), {0: "A", 1: "B"}) == [
+ ("A", "Tuner"),
+ ("A+B", "Toggle Bypass"),
+ ]
+
+
+def test_rows_from_bindings_skips_entries_without_longpress():
id_to_letter = {0: "A", 1: "B"}
entries = [{"id": 0, "midi_CC": 60}, {"id": 1, "longpress": "next_snapshot"}]
- assert _rows_from_entries(entries, id_to_letter) == [("B", "Snapshot +")]
+ assert _rows_from_bindings(_bindings(entries), id_to_letter) == [("B", "Snapshot +")]
-def test_label_for_mapping_preset_and_midi():
- assert _label_for_mapping({"midi_CC": 64}) == "MIDI CC 64"
- assert _label_for_mapping({"preset": "UP"}) == "Snapshot +"
- assert _label_for_mapping({"preset": "DOWN"}) == f"Snapshot {MINUS}"
- assert _label_for_mapping({"preset": 2}) == "Snapshot 2"
- assert _label_for_mapping({"pedalboard": "UP"}) == "Pedalboard +"
+def test_label_method_on_longpress_actions():
+ assert LongpressMidiCC(cc=64).label() == "MIDI CC 64"
+ assert LongpressPreset(preset=PresetStep.UP).label() == "Snapshot +"
+ assert LongpressPreset(preset=PresetStep.DOWN).label() == f"Snapshot {MINUS}"
+ assert LongpressPreset(preset=2).label() == "Snapshot 2"
+ assert LongpressBoard(direction="UP").label() == "Pedalboard +"
def _row_texts(dialog) -> list[str]:
@@ -122,6 +140,7 @@ def test_footswitch_menu_pedalboard_rows_and_divider(v3_system: SystemFixture, t
)
)
handler.current.pedalboard.bundle = str(bundle_dir)
+ handler.hardware.reinit(config.resolve(handler.hardware.default_cfg, bundle_dir))
lcd.footswitch_menu.open()
menu_panel = lcd.footswitch_menu._panel
diff --git a/tests/v3/test_footswitch_chords.py b/tests/v3/test_footswitch_chords.py
index 6a617a49a..e6c6cb398 100644
--- a/tests/v3/test_footswitch_chords.py
+++ b/tests/v3/test_footswitch_chords.py
@@ -37,7 +37,7 @@ def chords(v3_system: SystemFixture):
handler.chord_helper.rebuild(handler.callbacks)
for fs, groups in zip(v3_system.hw.footswitches, _CONFIG):
- fs.set_longpress_groups(groups)
+ fs.longpress_groups = list(groups)
handler.chord_helper.register(fs)
return fired
diff --git a/tests/v3/test_hardware_config.py b/tests/v3/test_hardware_config.py
index 8bbc951f1..7edd9ca0f 100644
--- a/tests/v3/test_hardware_config.py
+++ b/tests/v3/test_hardware_config.py
@@ -1,31 +1,34 @@
-"""Per-pedalboard hardware config overlay — reinit correctness tests.
+"""Per-pedalboard hardware config overlay — reinit correctness tests."""
-Green tests document existing behaviour.
-Red tests (encoder_switch_map, encoder longpress, footswitch disable) are
-written first and are expected to fail until the corresponding fixes land.
-"""
-
-import common.token as Token
+import pistomp.config as config
+from pistomp.config.adapt_v1 import adapt
+from pistomp.config.schema_v1 import merge
+from pistomp.controller import ControlType
from tests.types import SystemFixture
+from common.parameter import Parameter, PortInfo, Symbol
# ---------------------------------------------------------------------------
# Helpers
# ---------------------------------------------------------------------------
-def _cfg(footswitches=None, encoders=None):
- """Build a minimal hardware config dict for use with hw.reinit()."""
- hw: dict = {}
+def _cfg(hw, footswitches=None, encoders=None, analog_controls=None) -> config.PedalboardConfig:
+ """Resolve a pedalboard overlay against the fixture's default_config.yml."""
+ section: dict = {}
if footswitches is not None:
- hw[Token.FOOTSWITCHES] = footswitches
+ section["footswitches"] = footswitches
if encoders is not None:
- hw[Token.ENCODERS] = encoders
- return {Token.HARDWARE: hw}
+ section["encoders"] = encoders
+ if analog_controls is not None:
+ section["analog_controllers"] = analog_controls
+ overlay = config.parse({"hardware": section}, "")
+ return adapt(merge(hw.default_cfg, overlay))
# ---------------------------------------------------------------------------
# Footswitch longpress — existing behaviour
# ---------------------------------------------------------------------------
+
def test_footswitch_longpress_set_from_default(v3_system: SystemFixture):
"""FS0 longpress comes from default_config after fixture setup."""
hw = v3_system.hw
@@ -36,7 +39,7 @@ def test_footswitch_longpress_override(v3_system: SystemFixture):
"""Pedalboard config can change FS0 longpress to a different action."""
hw = v3_system.hw
- hw.reinit(_cfg(footswitches=[{"id": 0, "longpress": "toggle_bypass"}]))
+ hw.reinit(_cfg(hw, footswitches=[{"id": 0, "longpress": "toggle_bypass"}]))
assert "toggle_bypass" in hw.footswitches[0].longpress_groups
@@ -45,18 +48,51 @@ def test_footswitch_longpress_reset_to_default(v3_system: SystemFixture):
"""After an override, reinit(None) restores the default longpress."""
hw = v3_system.hw
- hw.reinit(_cfg(footswitches=[{"id": 0, "longpress": "toggle_bypass"}]))
- hw.reinit(None)
+ hw.reinit(_cfg(hw, footswitches=[{"id": 0, "longpress": "toggle_bypass"}]))
+ hw.reinit(adapt(merge(hw.default_cfg)))
assert "previous_snapshot" in hw.footswitches[0].longpress_groups
assert "toggle_bypass" not in hw.footswitches[0].longpress_groups
+def test_pedalboard_that_clears_the_section_restores_the_base(v3_system: SystemFixture):
+ """`footswitches: null` drops every entry from the merged document. The
+ control must go back to the base config, not keep the pedalboard before it."""
+ hw = v3_system.hw
+ hw.reinit(_cfg(hw, footswitches=[{"id": 0, "longpress": "toggle_bypass", "color": "red"}]))
+
+ overlay = config.parse({"hardware": {"footswitches": None}}, "")
+ hw.reinit(adapt(merge(hw.default_cfg, overlay)))
+
+ assert "previous_snapshot" in hw.footswitches[0].longpress_groups
+ assert hw.footswitches[0].lcd_color is None
+ assert hw.footswitches[0].midi_CC is not None
+
+
+def test_nav_encoder_takes_no_config(v3_system: SystemFixture):
+ """`id` is a screen position, so a config entry can name the same number as
+ the NAV encoder. NAV is excluded by type, not by id."""
+ hw = v3_system.hw
+ nav = next(e for e in hw.encoders if e.type is ControlType.NAV)
+ nav.id = 1
+
+ hw.reinit(adapt(merge(hw.default_cfg)))
+
+ assert nav.type is ControlType.NAV
+ assert nav.midi_CC is None
+
+
+def test_footswitch_unmentioned_keeps_default_longpress(v3_system: SystemFixture):
+ hw = v3_system.hw
+ hw.reinit(_cfg(hw, footswitches=[{"id": 0, "preset": 2}]))
+ assert "previous_snapshot" in hw.footswitches[0].longpress_groups
+
+
def test_footswitch_longpress_suppress_with_none(v3_system: SystemFixture):
"""Explicit null longpress in pedalboard config clears the default."""
hw = v3_system.hw
- hw.reinit(_cfg(footswitches=[{"id": 0, "longpress": None}]))
+ hw.reinit(_cfg(hw, footswitches=[{"id": 0, "longpress": None}]))
assert len(hw.footswitches[0].longpress_groups) == 0
@@ -65,35 +101,96 @@ def test_footswitch_longpress_suppress_with_none(v3_system: SystemFixture):
# Footswitch color — existing behaviour
# ---------------------------------------------------------------------------
+
def test_footswitch_color_override(v3_system: SystemFixture):
"""Pedalboard config can set FS0 lcd_color."""
hw = v3_system.hw
- hw.reinit(_cfg(footswitches=[{"id": 0, "color": "Red"}]))
+ hw.reinit(_cfg(hw, footswitches=[{"id": 0, "color": "Red"}]))
assert hw.footswitches[0].lcd_color == "Red"
-def test_footswitch_color_unaffected_without_key(v3_system: SystemFixture):
- """FS0 lcd_color is not changed when the override has no color key."""
+def test_footswitch_color_cleared_without_key(v3_system: SystemFixture):
+ """A pedalboard that sets no color gets the default, not the color of the
+ pedalboard before it."""
hw = v3_system.hw
- hw.reinit(_cfg(footswitches=[{"id": 0, "color": "Red"}]))
- hw.reinit(_cfg(footswitches=[{"id": 0, "longpress": "toggle_bypass"}])) # no color key
+ hw.reinit(_cfg(hw, footswitches=[{"id": 0, "color": "Red"}]))
+ hw.reinit(_cfg(hw, footswitches=[{"id": 0, "longpress": "toggle_bypass"}]))
- # Color persists because reinit doesn't clear it when the key is absent.
- assert hw.footswitches[0].lcd_color == "Red"
+ assert hw.footswitches[0].lcd_color is None
+
+
+def test_footswitch_preset_cleared_without_key(v3_system: SystemFixture):
+ """The same rule for preset. Nothing carries over from the last pedalboard."""
+ hw = v3_system.hw
+
+ hw.reinit(_cfg(hw, footswitches=[{"id": 0, "preset": "UP"}]))
+ assert hw.footswitches[0].preset_direction == "UP"
+
+ hw.reinit(_cfg(hw, footswitches=[{"id": 0, "color": "Red"}]))
+ assert hw.footswitches[0].preset_direction is None
+
+
+def test_footswitch_state_cleared_on_reinit(v3_system: SystemFixture):
+ """Toggle, label, category and plugin binding do not survive a pedalboard
+ change. get_display_label falls back to "" only when both midi_CC and
+ preset_callback_arg are clear, so a stale binding bleeds a dead label."""
+ hw = v3_system.hw
+ fs = hw.footswitches[0]
+
+ fs.toggled = True
+ fs.set_display_label("Reverb")
+ fs.set_category("Delay")
+ v3_system.handler.current.attach(fs, hw.create_external_parameter("probe", 0, 1))
+ v3_system.handler.current.close()
+
+ hw.reinit(_cfg(hw, footswitches=[{"id": 0, "preset": 1}]))
+
+ assert fs.toggled is False
+ assert fs.category is None
+ assert fs.parameter is None
+ assert fs.display_label == "1"
+
+
+def test_footswitch_binding_applies_by_id_not_position(v3_system: SystemFixture):
+ """Config is keyed by id. A footswitch missing from the object list must
+ not shift a later id's config onto the wrong switch."""
+ hw = v3_system.hw
+ kept = [fs for fs in hw.footswitches if fs.id != 1]
+ hw.footswitches = kept
+
+ hw.reinit(_cfg(hw, footswitches=[{"id": 2, "color": "Red"}]))
+
+ by_id = {fs.id: fs for fs in kept}
+ assert by_id[2].lcd_color == "Red"
+ assert by_id[0].lcd_color is None
+ assert by_id[3].lcd_color is None
+
+
+def test_disabled_footswitch_still_takes_its_other_fields(v3_system: SystemFixture):
+ """disable does not stop the rest of the entry from applying, so a disabled
+ switch cannot keep the colour of the pedalboard before it."""
+ hw = v3_system.hw
+
+ hw.reinit(_cfg(hw, footswitches=[{"id": 0, "color": "Red"}]))
+ hw.reinit(_cfg(hw, footswitches=[{"id": 0, "disable": True, "color": "Blue"}]))
+
+ assert hw.footswitches[0].disabled is True
+ assert hw.footswitches[0].lcd_color == "Blue"
# ---------------------------------------------------------------------------
# Footswitch disable — NEW: expected to fail until fix lands
# ---------------------------------------------------------------------------
+
def test_footswitch_disable_override(v3_system: SystemFixture):
"""Pedalboard config can mark FS0 as disabled."""
hw = v3_system.hw
- hw.reinit(_cfg(footswitches=[{"id": 0, "disable": True}]))
+ hw.reinit(_cfg(hw, footswitches=[{"id": 0, "disable": True}]))
assert hw.footswitches[0].disabled is True
@@ -102,8 +199,8 @@ def test_footswitch_disable_reset_to_enabled(v3_system: SystemFixture):
"""Disabled FS resets to enabled when a different pedalboard is loaded."""
hw = v3_system.hw
- hw.reinit(_cfg(footswitches=[{"id": 0, "disable": True}]))
- hw.reinit(None) # new pedalboard with no overrides
+ hw.reinit(_cfg(hw, footswitches=[{"id": 0, "disable": True}]))
+ hw.reinit(adapt(merge(hw.default_cfg))) # new pedalboard with no overrides
assert hw.footswitches[0].disabled is False
@@ -112,7 +209,7 @@ def test_footswitch_disabled_does_not_respond(v3_system: SystemFixture):
"""A disabled footswitch ignores poll() events."""
hw = v3_system.hw
- hw.reinit(_cfg(footswitches=[{"id": 0, "disable": True}]))
+ hw.reinit(_cfg(hw, footswitches=[{"id": 0, "disable": True}]))
fs0 = hw.footswitches[0]
fires = []
@@ -133,6 +230,7 @@ def test_footswitch_disabled_does_not_respond(v3_system: SystemFixture):
# Encoder longpress — stored as string name; resolved by handler at dispatch.
# ---------------------------------------------------------------------------
+
def _enc(hw, enc_id):
return next(e for e in hw.encoders if getattr(e, "id", None) == enc_id)
@@ -148,7 +246,7 @@ def test_encoder_longpress_override(v3_system: SystemFixture):
"""Pedalboard config can change enc1 longpress to toggle_bypass."""
hw = v3_system.hw
- hw.reinit(_cfg(encoders=[{"id": 1, "longpress": "toggle_bypass"}]))
+ hw.reinit(_cfg(hw, encoders=[{"id": 1, "longpress": "toggle_bypass"}]))
assert _enc(hw, 1).longpress == "toggle_bypass"
@@ -157,8 +255,8 @@ def test_encoder_longpress_reset_to_default(v3_system: SystemFixture):
"""After an encoder longpress override, reinit(None) restores the default."""
hw = v3_system.hw
- hw.reinit(_cfg(encoders=[{"id": 1, "longpress": "toggle_bypass"}]))
- hw.reinit(None)
+ hw.reinit(_cfg(hw, encoders=[{"id": 1, "longpress": "toggle_bypass"}]))
+ hw.reinit(adapt(merge(hw.default_cfg)))
assert _enc(hw, 1).longpress == "previous_snapshot"
@@ -167,7 +265,7 @@ def test_encoder_longpress_suppress_with_none(v3_system: SystemFixture):
"""Explicit null in pedalboard config clears the default encoder longpress."""
hw = v3_system.hw
- hw.reinit(_cfg(encoders=[{"id": 1, "longpress": None}]))
+ hw.reinit(_cfg(hw, encoders=[{"id": 1, "longpress": None}]))
assert _enc(hw, 1).longpress is None
@@ -176,22 +274,87 @@ def test_encoder_unmentioned_keeps_default(v3_system: SystemFixture):
"""Overriding enc2 does not disturb enc1's default longpress."""
hw = v3_system.hw
- hw.reinit(_cfg(encoders=[{"id": 2, "longpress": "toggle_bypass"}]))
+ hw.reinit(_cfg(hw, encoders=[{"id": 2, "longpress": "toggle_bypass"}]))
assert _enc(hw, 1).longpress == "previous_snapshot"
-def test_longpress_enum_covers_every_handler_callback(v3_system: SystemFixture):
- """The schema enum and the handler's callback map must not drift — a name
- the handler answers to but the schema rejects logs a config error on load
- and silently drops the group at registration."""
- from pistomp.config import schema
+def test_encoder_longpress_cleared_when_default_omits_it(v3_system: SystemFixture):
+ """Encoder 3 is the VOLUME encoder and carries no longpress in
+ default_config.yml. An override must still go away with the pedalboard."""
+ hw = v3_system.hw
+ enc = _enc(hw, 3)
+
+ hw.reinit(_cfg(hw, encoders=[{"id": 3, "longpress": "toggle_bypass"}]))
+ assert enc.longpress == "toggle_bypass"
+
+ hw.reinit(adapt(merge(hw.default_cfg)))
+ assert enc.longpress is None
+
+
+def test_external_midi_messages_do_not_accumulate(v3_system: SystemFixture):
+ """Messages belong to the pedalboard that declared them."""
+ hw = v3_system.hw
+ assert hw.external_midi is not None
+
+ first = {"hardware": {"external_midi": {"enabled": True, "messages": {"HX Stomp": [[0xC0, 0x01]]}}}}
+ hw.reinit(adapt(merge(hw.default_cfg, config.parse(first, ""))))
+ assert "HX Stomp" in hw.external_midi.messages
+
+ hw.reinit(adapt(merge(hw.default_cfg)))
+ assert hw.external_midi.messages == {}
+
+
+def test_longpress_names_cover_every_handler_callback(v3_system: SystemFixture):
+ """The accepted longpress names and the handler's callback map must not
+ drift. A name the handler answers to but the parser rejects fails the whole
+ config on load."""
+ from typing import get_args
+
+ from pistomp.config.schema_v1 import LongpressName
- enum = set(
- schema["properties"]["hardware"]["properties"]["footswitches"]["items"]["properties"][
- "longpress"
- ]["oneOf"][0]["enum"]
- )
# set_mod_tap_tempo shares the callback map but is reachable only via the
# `tap_tempo:` key, which passes a BPM no longpress can supply.
- assert set(v3_system.handler.callbacks) - {"set_mod_tap_tempo"} == enum
+ assert set(v3_system.handler.callbacks) - {"set_mod_tap_tempo"} == set(get_args(LongpressName))
+
+
+def test_reinit_unsubscribes_old_parameter(v3_system: SystemFixture):
+ hw = v3_system.hw
+ fs = hw.footswitches[0]
+ old_param = Parameter(
+ PortInfo(name="Bypass", symbol=Symbol("bypass"), ranges={"minimum": 0.0, "maximum": 1.0}),
+ 0.0,
+ "0:60",
+ "OldPlugin",
+ )
+ v3_system.handler.current.bind(fs, old_param)
+
+ v3_system.handler.current.close()
+ hw.reinit(_cfg(hw, footswitches=[{"id": 0, "preset": 1}]))
+ fs.toggled = True
+ old_param.reconcile(1.0)
+
+ assert fs.parameter is None
+ assert fs.toggled is True
+
+
+def test_encoder_disable_removes_controller(v3_system: SystemFixture):
+ hw = v3_system.hw
+ enc = _enc(hw, 1)
+
+ hw.reinit(_cfg(hw, encoders=[{"id": 1, "disable": True}]))
+
+ assert all(controller is not enc for controller in hw.controllers.values())
+
+
+
+
+def test_encoder_type_transition_rebinds_volume(v3_system: SystemFixture):
+ hw = v3_system.hw
+ enc = _enc(hw, 1)
+
+ hw.reinit(_cfg(hw, encoders=[{"id": 1, "type": "VOLUME"}]))
+ v3_system.handler.bind_volume_encoder()
+
+ assert enc.type == "VOLUME"
+ assert enc.parameter is v3_system.handler.volume_parameter
diff --git a/tests/v3/test_longpress_actions.py b/tests/v3/test_longpress_actions.py
index 7f800c1cf..ce52819c0 100644
--- a/tests/v3/test_longpress_actions.py
+++ b/tests/v3/test_longpress_actions.py
@@ -17,6 +17,7 @@
from pistomp.footswitch import Footswitch
from pistomp.input.event import SwitchEvent, SwitchEventKind
from tests.types import SystemFixture
+from tests.v3.test_hardware_config import _cfg
def _fs_key(fs: Footswitch) -> str:
@@ -39,10 +40,10 @@ def _longpress_rows(handler, key):
def test_longpress_raw_midi_cc(v3_system: SystemFixture):
handler = v3_system.handler
- fs = v3_system.hw.footswitches[0]
- fs.add_preset(direction="UP")
- fs.longpress_action = {"midi_CC": 64}
+ hw = v3_system.hw
+ hw.reinit(_cfg(hw, footswitches=[{"id": 0, "longpress": {"midi_CC": 64}, "preset": "UP"}]))
handler.bind_current_pedalboard()
+ fs = hw.footswitches[0]
rows = _longpress_rows(handler, _fs_key(fs))
assert len(rows) == 1
@@ -63,9 +64,10 @@ def test_longpress_raw_midi_cc(v3_system: SystemFixture):
def test_longpress_raw_midi_cc_drift_self_corrects(v3_system: SystemFixture):
handler = v3_system.handler
- fs = v3_system.hw.footswitches[0]
- fs.longpress_action = {"midi_CC": 64}
+ hw = v3_system.hw
+ hw.reinit(_cfg(hw, footswitches=[{"id": 0, "longpress": {"midi_CC": 64}}]))
handler.bind_current_pedalboard()
+ fs = hw.footswitches[0]
event = SwitchEvent(controller=fs, kind=SwitchEventKind.LONGPRESS, timestamp=1.0)
sends = v3_system.hw.midiout.send_message
@@ -88,9 +90,10 @@ def test_longpress_raw_midi_cc_drift_self_corrects(v3_system: SystemFixture):
def test_longpress_snapshot_specific_index(v3_system: SystemFixture, monkeypatch):
handler = v3_system.handler
- fs = v3_system.hw.footswitches[0]
- fs.longpress_action = {"preset": 2}
+ hw = v3_system.hw
+ hw.reinit(_cfg(hw, footswitches=[{"id": 0, "longpress": {"preset": 2}}]))
handler.bind_current_pedalboard()
+ fs = hw.footswitches[0]
rows = _longpress_rows(handler, _fs_key(fs))
assert any(isinstance(e, PresetEffect) and e.direction == "2" for e in rows[0].effects)
@@ -104,12 +107,13 @@ def test_longpress_snapshot_specific_index(v3_system: SystemFixture, monkeypatch
def test_longpress_snapshot_up_down(v3_system: SystemFixture):
handler = v3_system.handler
hw = v3_system.hw
-
+ hw.reinit(_cfg(hw, footswitches=[
+ {"id": 0, "longpress": {"preset": "UP"}},
+ {"id": 1, "longpress": {"preset": "DOWN"}},
+ ]))
+ handler.bind_current_pedalboard()
fs_up = hw.footswitches[0]
- fs_up.longpress_action = {"preset": "UP"}
fs_dn = hw.footswitches[1]
- fs_dn.longpress_action = {"preset": "DOWN"}
- handler.bind_current_pedalboard()
calls: list[str] = []
handler.preset_incr_and_change = lambda *a: calls.append("incr")
@@ -192,21 +196,3 @@ def test_pedalboard_nav_single_pedalboard_bank(v3_system: SystemFixture):
assert changed == [pbs[0], pbs[0]]
-# ---------------------------------------------------------------------------
-# Chord form still works
-# ---------------------------------------------------------------------------
-
-
-def test_longpress_chord_form_still_works(v3_system: SystemFixture):
- fs = v3_system.hw.footswitches[0]
- fs.set_longpress_groups("next_snapshot")
- assert fs.longpress_groups == ["next_snapshot"]
- assert fs.longpress_action is None
-
- fs.set_longpress_groups(["next_snapshot", "toggle_bypass"])
- assert fs.longpress_groups == ["next_snapshot", "toggle_bypass"]
- assert fs.longpress_action is None
-
- fs.set_longpress_groups({"midi_CC": 64})
- assert fs.longpress_groups == []
- assert fs.longpress_action == {"midi_CC": 64}
diff --git a/tests/v3/test_midi_learn.py b/tests/v3/test_midi_learn.py
index 6ea8e0cba..88b1091e8 100644
--- a/tests/v3/test_midi_learn.py
+++ b/tests/v3/test_midi_learn.py
@@ -2,7 +2,7 @@
plugin parameter live, so the LCD reflects it without a pedalboard reload."""
import common.util as util
-from common.contexts import ControlClass, EventKind, ParamEffect
+from common.contexts import ControlClass, EventKind, MidiCcEffect, ParamEffect
from common.parameter import BYPASS_SYMBOL, Parameter, PortInfo, Symbol
from tests.types import SystemFixture
@@ -446,6 +446,59 @@ def test_v3_midi_unlearn_footswitch_clears_binding(v3_system: SystemFixture, mak
snapshot("unbound")
+def test_v3_midi_unlearn_restores_footswitch_default_action(v3_system: SystemFixture, make_plugin):
+ """A footswitch must still work after its mapping is removed in MOD-UI.
+
+ While plugin-bound it has a ParamEffect PRESS row and no default CC-toggle
+ row, so dropping the learned row on its own leaves it with no PRESS row at
+ all — pressing it does nothing, and its keycap stays lit at the plugin's
+ last reported value."""
+ import pistomp.switchstate as switchstate
+
+ handler = v3_system.handler
+ hw = v3_system.hw
+ ws_bridge = v3_system.ws_bridge
+
+ assert handler.current and handler.lcd
+
+ fs0 = hw.footswitches[0]
+ assert fs0.midi_CC is not None
+ binding_id = _binding_for(hw, fs0)
+ channel, cc = binding_id.split(":")
+
+ def press_rows():
+ rows = handler.effective_table.layers[0].rows.get((ControlClass.FOOTSWITCH, EventKind.PRESS), [])
+ return [r for r in rows if r.control.id == binding_id]
+
+ plugin = make_plugin("noise", bypassed=False, has_footswitch=False)
+ handler.current.pedalboard.plugins = [plugin]
+ handler.lcd.link_data(handler.pedalboard_list, handler.current, hw.footswitches)
+ handler.lcd.draw_main_panel()
+
+ ws_bridge.inject(f"midi_map /graph/noise :bypass {channel} {cc} 0.0 1.0")
+ handler.poll_ws_messages()
+ bound = press_rows()
+ assert len(bound) == 1
+ assert any(isinstance(e, ParamEffect) for e in bound[0].effects)
+ assert fs0.toggled is True
+
+ ws_bridge.inject("midi_map /graph/noise :bypass -1 -1 0.0 1.0")
+ handler.poll_ws_messages()
+
+ # The default CC-toggle row is back, and the learned row is gone.
+ unbound = press_rows()
+ assert len(unbound) == 1
+ assert any(isinstance(e, MidiCcEffect) for e in unbound[0].effects)
+ assert not any(isinstance(e, ParamEffect) for e in unbound[0].effects)
+
+ # No echo reaches an unbound switch, so it must not stay lit.
+ assert fs0.toggled is False
+
+ # And it dispatches again.
+ fs0._on_switch(switchstate.Value.RELEASED)
+ assert fs0.toggled is True
+
+
def test_v3_midi_learn_updated_binding_range_on_same_parameter(v3_system: SystemFixture, make_plugin, make_parameter):
"""Re-addressing an already bound parameter to a different sub-range on the same CC
updates the parameter's binding range and endpoints without bailing early."""
@@ -581,7 +634,9 @@ def test_v3_midi_learn_moving_footswitch_binding_clears_old_lcd_display(v3_syste
assert plugin.controllers.count(fs0) == 0
assert plugin.controllers.count(fs1) == 1
- # LCD: FS0 reverted to unbound grey; FS1 active
+ # LCD: FS0 reverted to unbound grey; FS1 active. Re-fetch — a re-derive
+ # rebuilds the footswitch widgets, so the earlier reference is detached.
+ w0 = next(w for w in lcd.w_footswitches if w.object is fs0)
assert w0.color is None
assert w0.action is None
w1 = next(w for w in lcd.w_footswitches if w.object is fs1)
diff --git a/tests/v3/test_notes_panel.py b/tests/v3/test_notes_panel.py
index d639743dc..8912145ec 100644
--- a/tests/v3/test_notes_panel.py
+++ b/tests/v3/test_notes_panel.py
@@ -14,7 +14,7 @@
from plugins.notes import NOTES_URI
from plugins.notes.panel import NotesData, NotesPanel
from tests.types import SystemFixture
-import common.token as Token
+from pistomp.controller import ControlType
from tests.v3.nav_helpers import nav_click
from common.parameter import BYPASS_SYMBOL, PortInfo, Symbol
@@ -52,7 +52,7 @@ class _NavEnc(Controller):
def __init__(self) -> None:
super().__init__(midi_channel=0, midi_CC=None)
- self.type = Token.NAV
+ self.type = ControlType.NAV
self.id = 0
diff --git a/tests/v3/test_plugins.py b/tests/v3/test_plugins.py
index df0e65773..408d34460 100644
--- a/tests/v3/test_plugins.py
+++ b/tests/v3/test_plugins.py
@@ -14,12 +14,15 @@
from common.parameter import BYPASS_SYMBOL, Parameter, PortInfo, Symbol
from common.parameter_steps import ParameterSteps
from modalapi.plugin import Plugin
-import common.token as Token
+from pistomp.controller import ControlType
+from pistomp.config.adapt_v1 import adapt
+from pistomp.config.schema_v1 import merge
from tests.types import SystemFixture
from modalapi.connections import Connection, Endpoint, EndpointKind
from plugins.customization import lookup
from plugins.nam import NAM_URIS
from uilib.text import TextWidget
+from tests.v3.bind_helpers import bind_bypass
from tests.v3.nav_helpers import nav_click
@@ -80,7 +83,7 @@ def test_v3_bind_volume_encoder_populates_analog_controllers(v3_system: SystemFi
handler.bind_current_pedalboard()
- assert Token.VOLUME in handler.current.analog_controllers
+ assert ControlType.VOLUME in handler.current.analog_controllers
def test_v3_bind_does_not_reorder_footswitch_plugins(v3_system: SystemFixture, make_plugin):
@@ -129,8 +132,7 @@ def test_v3_toggle_plugin_bypass_via_footswitch_sends_midi_cc(v3_system: SystemF
assert fs.midi_CC is not None, "test requires a footswitch with a midi_CC binding"
plugin = make_plugin("fuzz")
- handler._bind_controller_to_param(plugin, plugin.parameters[BYPASS_SYMBOL], fs)
- handler.current.pedalboard.plugins = [plugin]
+ bind_bypass(v3_system, plugin, fs)
handler.toggle_plugin_bypass(plugin)
@@ -249,8 +251,7 @@ def test_v3_toggle_plugin_bypass_via_footswitch(v3_system: SystemFixture, make_p
assert handler.current
plugin = make_plugin("fuzz")
- handler._bind_controller_to_param(plugin, plugin.parameters[BYPASS_SYMBOL], hw.footswitches[0])
- handler.current.pedalboard.plugins = [plugin]
+ bind_bypass(v3_system, plugin, hw.footswitches[0])
handler.lcd.link_data(handler.pedalboard_list, handler.current, hw.footswitches)
handler.lcd.draw_main_panel()
@@ -854,7 +855,7 @@ def test_v3_pedalboard_switch_multi_fs_same_plugin_show_bound_off_color(
)
handler.current.pedalboard.plugins = [doom]
- hw.reinit(None)
+ hw.reinit(adapt(merge(hw.default_cfg)))
handler.bind_current_pedalboard()
handler.lcd.link_data(handler.pedalboard_list, handler.current, hw.footswitches)
handler.lcd.draw_main_panel()
diff --git a/tests/v3/test_reactive_parameter.py b/tests/v3/test_reactive_parameter.py
index 127708090..77c473927 100644
--- a/tests/v3/test_reactive_parameter.py
+++ b/tests/v3/test_reactive_parameter.py
@@ -20,6 +20,7 @@
from plugins.fullscreen import FullscreenPluginPanel
from plugins.window import PluginWindow
from tests.types import SystemFixture
+from tests.v3.bind_helpers import bind_bypass
from uilib.parameterdialog import Parameterdialog
@@ -448,10 +449,8 @@ def test_connect_dump_coalesces_apply_state(v3_system: SystemFixture, make_plugi
def _bind_footswitch(v3_system: SystemFixture, plugin: Plugin):
"""Bind footswitch[0] to the plugin's :bypass, mirroring production setup."""
- handler = v3_system.handler
- hw = v3_system.hw
- fs = hw.footswitches[0]
- handler._bind_controller_to_param(plugin, plugin.parameters[BYPASS_SYMBOL], fs)
+ fs = v3_system.hw.footswitches[0]
+ bind_bypass(v3_system, plugin, fs)
return fs
diff --git a/tests/v3/test_startup.py b/tests/v3/test_startup.py
index 68171888f..ad7b0796b 100644
--- a/tests/v3/test_startup.py
+++ b/tests/v3/test_startup.py
@@ -1,7 +1,7 @@
"""Startup, basic navigation, and footswitch press — smoke tests for the full stack."""
import pistomp.switchstate as switchstate
-import common.token as Token
+from pistomp.controller import ControlType
from pistomp.encoder_controller import EncoderController
from tests.v3.nav_helpers import nav_click
@@ -38,7 +38,7 @@ def test_v3_nav_encoder_button_press_opens_system_menu(v3_system, snapshot):
"""Nav encoder button press routes through the sink pipeline to lcd.enc_sw."""
hw = v3_system.hw
- nav_enc = next(e for e in hw.encoders if isinstance(e, EncoderController) and e.type == Token.NAV)
+ nav_enc = next(e for e in hw.encoders if isinstance(e, EncoderController) and e.type == ControlType.NAV)
nav_enc._on_button(switchstate.Value.RELEASED, timestamp=0.0)
snapshot()
diff --git a/ui/footswitch_menu.py b/ui/footswitch_menu.py
index 8098f39eb..d612f3a11 100644
--- a/ui/footswitch_menu.py
+++ b/ui/footswitch_menu.py
@@ -15,24 +15,15 @@
# You should have received a copy of the GNU Affero General Public License
# along with pi-stomp. If not, see .
-"""Footswitch long-press bindings menu.
-
-A read-only list, opened by long-pressing the footswitch bar: single-switch
-longpress actions, a divider, then the chords. The pedalboard's own config.yml
-shadows default_config.yml per footswitch id, as Hardware.__init_footswitches
-does. Built directly on Dialog (like EthernetMenu/WifiMenu) rather than
-ModalDialog/PluginPanel — there's no Plugin or reactive parameter state behind
-a static config listing.
-"""
-
from __future__ import annotations
from pathlib import Path
-from typing import TYPE_CHECKING, Any
-
-import yaml
+from typing import TYPE_CHECKING, Sequence
-import common.token as Token
+from pistomp.config.model import (
+ FootswitchBinding,
+ MINUS,
+)
from plugins.chrome import BTN_GAP, BTN_H
from uilib import Box, Config, Dialog, TextWidget, WidgetAlign, get_text_size
from uilib.paint import PaintContext
@@ -49,12 +40,7 @@
ROW_PAD = 4
DIVIDER_H = 8
-# U+2212: the ASCII hyphen is half the width of "+" and reads as a dash, not
-# an operator, next to it.
-MINUS = "−"
-
-# Only the string/list longpress enum (pistomp/config.py schema); the mapping
-# form (midi_CC/preset/pedalboard) is handled separately by _label_for_mapping.
+# Chord-form action names. The mapping form makes its own label.
_ACTION_LABELS = {
"next_snapshot": "Snapshot +",
"previous_snapshot": f"Snapshot {MINUS}",
@@ -69,42 +55,22 @@
def _label_for_action(name: str) -> str:
return _ACTION_LABELS.get(name, name)
-
-def _label_for_mapping(action: dict[str, Any]) -> str:
- if Token.MIDI_CC in action:
- return f"MIDI CC {action[Token.MIDI_CC]}"
- # config.yml says "preset"; MOD and the rest of the UI say "snapshot".
- if Token.PRESET in action:
- value = action[Token.PRESET]
- if value == Token.UP:
- return "Snapshot +"
- if value == Token.DOWN:
- return f"Snapshot {MINUS}"
- return f"Snapshot {value}"
- value = action["pedalboard"]
- return "Pedalboard +" if value == Token.UP else f"Pedalboard {MINUS}"
-
-
-def _rows_from_entries(entries: list[dict[str, Any]], id_to_letter: dict[int, str]) -> list[tuple[str, str]]:
- """(letters, label) rows: footswitches sharing a string/list longpress
- name chord together (FootswitchChords groups by name); a mapping-form
- longpress is always its own row — it never joins a named group (see
- Footswitch.set_longpress_groups)."""
+def _rows_from_bindings(bindings: Sequence[FootswitchBinding], id_to_letter: dict[int, str]) -> list[tuple[str, str]]:
+ """(letters, label) rows: footswitches that share a longpress name chord
+ together. A mapping-form longpress is always its own row."""
groups: dict[object, list[int]] = {}
labels: dict[object, str] = {}
- for entry in entries:
- longpress = entry.get(Token.LONGPRESS)
+ for binding in bindings:
+ longpress = binding.longpress
if longpress is None:
continue
- fs_id = entry[Token.ID]
- if isinstance(longpress, dict):
- groups.setdefault(fs_id, []).append(fs_id)
- labels[fs_id] = _label_for_mapping(longpress)
- else:
- names = longpress.split() if isinstance(longpress, str) else longpress
- for name in names:
- groups.setdefault(name, []).append(fs_id)
+ if isinstance(longpress, tuple):
+ for name in longpress:
+ groups.setdefault(name, []).append(binding.id)
labels[name] = _label_for_action(name)
+ else:
+ groups.setdefault(binding.id, []).append(binding.id)
+ labels[binding.id] = longpress.label()
rows = []
for key, ids in groups.items():
@@ -121,20 +87,6 @@ def _partition_rows(rows: list[tuple[str, str]]) -> tuple[list[tuple[str, str]],
return singles, chords
-def _pedalboard_footswitch_entries(bundle: str) -> tuple[list[dict[str, Any]], set[int]]:
- """Raw footswitch entries from this pedalboard's own config.yml, and the
- full set of ids it touches — including entries with no longpress: of
- their own, since Hardware.__init_footswitches's clear_pedalboard_info()
- wipes any *default* longpress for those ids too."""
- config_file = Path(bundle) / "config.yml"
- if not config_file.exists():
- return [], set()
- with open(config_file, "r") as f:
- cfg = yaml.load(f, Loader=yaml.SafeLoader)
- entries = ((cfg or {}).get(Token.HARDWARE) or {}).get(Token.FOOTSWITCHES) or []
- return entries, {e[Token.ID] for e in entries}
-
-
class _BindingsDialog(Dialog):
divider_y: int | None = None
divider_color = (70, 70, 70)
@@ -146,24 +98,16 @@ def _draw(self, ctx: PaintContext) -> None:
class FootswitchMenu:
- """Opened by long-pressing the footswitch bar. Mirrors EthernetMenu: a
- single Dialog pushed onto the panel stack, dismissed by its own Back
- button — content is rebuilt fresh on every open() rather than tracked
- live, since it only depends on the pedalboard that's already current."""
+ """Shows the long-press bindings for all footswitches, grouped by name."""
def __init__(self, lcd: "Lcd") -> None:
self.lcd = lcd
self._panel: Dialog | None = None
def open(self) -> None:
- hardware = self.lcd.handler.hardware
- default_entries = hardware.default_cfg[Token.HARDWARE][Token.FOOTSWITCHES]
- id_to_letter = {e[Token.ID]: chr(ord("A") + e[Token.ID]) for e in default_entries}
-
- bundle = self.lcd.handler.current.pedalboard.bundle
- pb_entries, pb_ids = _pedalboard_footswitch_entries(bundle)
- merged = [e for e in default_entries if e[Token.ID] not in pb_ids] + pb_entries
- single_rows, chord_rows = _partition_rows(_rows_from_entries(merged, id_to_letter))
+ bindings = self.lcd.handler.hardware.config.footswitches
+ id_to_letter = {b.id: chr(ord("A") + b.id) for b in bindings}
+ single_rows, chord_rows = _partition_rows(_rows_from_bindings(bindings, id_to_letter))
show_divider = bool(single_rows) and bool(chord_rows)
num_rows = len(single_rows) + len(chord_rows)
diff --git a/uilib/panel.py b/uilib/panel.py
index 8847e7083..8c4adc491 100644
--- a/uilib/panel.py
+++ b/uilib/panel.py
@@ -30,7 +30,7 @@
from uilib.misc import InputEvent, trace
from uilib.paint import PaintContext, _pg_rect
-import common.token as Token
+from pistomp.controller import ControlType
from common.contexts import BindingDecl
from pistomp.input.event import ControllerEvent, EncoderEvent, SwitchEvent, SwitchEventKind
from pistomp.input.sink import InputSink
@@ -221,12 +221,12 @@ def _get_panel(self):
def handle(self, event: ControllerEvent) -> bool:
match event:
- case EncoderEvent() if event.controller.type == Token.NAV:
+ case EncoderEvent() if event.controller.type == ControlType.NAV:
d = event.rotations
if d == 0:
return True
return self.input_step(1 if d > 0 else -1, abs(d), event.multiplier)
- case SwitchEvent() if event.controller.type == Token.NAV:
+ case SwitchEvent() if event.controller.type == ControlType.NAV:
click = InputEvent.LONG_CLICK if event.kind is SwitchEventKind.LONGPRESS else InputEvent.CLICK
return self.input_event(click)
if self.on_event(event):
@@ -234,7 +234,7 @@ def handle(self, event: ControllerEvent) -> bool:
match event:
case SwitchEvent() if (
event.kind is SwitchEventKind.PRESS
- and event.controller.type in (Token.KNOB, Token.VOLUME)
+ and event.controller.type in (ControlType.KNOB, ControlType.VOLUME)
):
return self.input_event(InputEvent.CLICK)
return False
diff --git a/uv.lock b/uv.lock
index cbcff649d..429c2ce85 100644
--- a/uv.lock
+++ b/uv.lock
@@ -752,6 +752,54 @@ wheels = [
{ url = "https://files.pythonhosted.org/packages/73/e4/6d6f14b2a759c622f191b2d67e9075a3f56aaccb3be4bb9bb6890030d0a0/matplotlib-3.10.8-pp311-pypy311_pp73-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:1ae029229a57cd1e8fe542485f27e7ca7b23aa9e8944ddb4985d0bc444f1eca2", size = 8713867, upload-time = "2025-12-10T22:56:48.954Z" },
]
+[[package]]
+name = "msgspec"
+version = "0.21.1"
+source = { registry = "https://pypi.org/simple" }
+sdist = { url = "https://files.pythonhosted.org/packages/e3/60/f79b9b013a16fa3a58350c9295ddc6789f2e335f36ea61ed10a21b215364/msgspec-0.21.1.tar.gz", hash = "sha256:2313508e394b0d208f8f56892ca9b2799e2561329de9763b19619595a6c0f72c", size = 319193, upload-time = "2026-04-12T21:44:50.394Z" }
+wheels = [
+ { url = "https://files.pythonhosted.org/packages/ba/7f/bbc4e74cd33d316b75541149e4d35b163b63bce066530ae185a2ec3b5bfc/msgspec-0.21.1-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:b504b6e7f7a22a24b27232b73034421692147865162daaec9f3bf62439007c87", size = 193131, upload-time = "2026-04-12T21:43:56.094Z" },
+ { url = "https://files.pythonhosted.org/packages/c1/60/504886af1aaf854112663b842d5eea9a15d9588f9bf7d0d2df736424b84d/msgspec-0.21.1-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:4692b7c1609155708c4418f88e92f63c13fdf08aa095c84bae82bad75b53389b", size = 186597, upload-time = "2026-04-12T21:43:57.242Z" },
+ { url = "https://files.pythonhosted.org/packages/fa/54/d24ddeaa65b5278c9e67f48ce3c17a9831e8f3722f3c8322ee120aca22ef/msgspec-0.21.1-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:d3124010b3815451494c85ff345e693cb9fe5889cfcbbef39ed8622e0e72319c", size = 215158, upload-time = "2026-04-12T21:43:58.442Z" },
+ { url = "https://files.pythonhosted.org/packages/9f/75/bb79c8b89a93ae23cd33c0d802373f16feaf9633f05d8af77091350dda0a/msgspec-0.21.1-cp311-cp311-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:6badc03b9725352219cca017bfe71c61f2fbd0fb5982b410ac17c97c213deb30", size = 219856, upload-time = "2026-04-12T21:44:00.015Z" },
+ { url = "https://files.pythonhosted.org/packages/b4/9c/c5ca26b46f0ebbd3a6683695ef89396712cb9e4199fd1f0bc1dd968216b1/msgspec-0.21.1-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:5d2d4116ebe3035a78d9ec76e99a9d64e5fa6d44fe61a9c5de7fd1acf54bcc69", size = 220314, upload-time = "2026-04-12T21:44:01.548Z" },
+ { url = "https://files.pythonhosted.org/packages/c8/31/645a351c4285dce40ed6755c3dcc0aa648e26dacb20a98018fe2cce5e87b/msgspec-0.21.1-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:0d1009f6715f5bff3b54d4ff5c7428ad96197e0534e1645b8e9b955890c84664", size = 223215, upload-time = "2026-04-12T21:44:02.884Z" },
+ { url = "https://files.pythonhosted.org/packages/09/af/8bf15736a6dd3cb4f90c5467f6dc39197d2daaf10754490cdc0aa17b7312/msgspec-0.21.1-cp311-cp311-win_amd64.whl", hash = "sha256:c6faffe5bb644ec884052679af4dfd776d4b5ca90e4a7ec7e7e319e4e6b93a6e", size = 188554, upload-time = "2026-04-12T21:44:04.151Z" },
+ { url = "https://files.pythonhosted.org/packages/ef/29/cc7db3a165b62d16e64a83f82eccb79655055cb5bc1f60459a6f9d7c82f2/msgspec-0.21.1-cp311-cp311-win_arm64.whl", hash = "sha256:ee9e3f11fa94603f7d673bf795cfa31b549c4a2c723bc39b45beb1e7f5a3fb99", size = 174517, upload-time = "2026-04-12T21:44:05.66Z" },
+ { url = "https://files.pythonhosted.org/packages/6e/cf/317224852c00248c620a9bcf4b26e2e4ab8afd752f18d2a6ef73ebd423b6/msgspec-0.21.1-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:d4248cf0b6129b7d230eacd493c17cc2d4f3989f3bb7f633a928a85b7dcfa251", size = 196188, upload-time = "2026-04-12T21:44:07.181Z" },
+ { url = "https://files.pythonhosted.org/packages/6d/81/074612945c0666078f7366f40000013de9f6ba687491d450df699bceebc9/msgspec-0.21.1-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:5102c7e9b3acff82178449b85006d96310e690291bb1ea0142f1b24bcb8aabcb", size = 188473, upload-time = "2026-04-12T21:44:08.736Z" },
+ { url = "https://files.pythonhosted.org/packages/8a/37/655101799590bcc5fddb2bd3fe0e6194e816c2d1da7c361725f5eb89a910/msgspec-0.21.1-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:846758412e9518252b2ac9bffd6f0e54d9ff614f5f9488df7749f81ff5c80920", size = 218871, upload-time = "2026-04-12T21:44:09.917Z" },
+ { url = "https://files.pythonhosted.org/packages/b5/d1/d4cd9fe89c7d400d7a18f86ccc94daa3f0927f53558846fcb60791dce5d6/msgspec-0.21.1-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:21995e74b5c598c2e004110ad66ec7f1b8c20bf2bcf3b2de8fd9a3094422d3ff", size = 225025, upload-time = "2026-04-12T21:44:11.191Z" },
+ { url = "https://files.pythonhosted.org/packages/24/bf/e20549e602b9edccadeeff98760345a416f9cce846a657e8b18e3396b212/msgspec-0.21.1-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:6129f0cca52992e898fd5344187f7c8127b63d810b2fd73e36fca73b4c6475ee", size = 222672, upload-time = "2026-04-12T21:44:12.481Z" },
+ { url = "https://files.pythonhosted.org/packages/b4/68/04d7a8f0f786545cf9b8c280c57aa6befb5977af6e884b8b54191cbe44b3/msgspec-0.21.1-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:ef3ec2296248d1f8b9231acb051b6d471dfde8f21819e86c9adaaa9f42918521", size = 227303, upload-time = "2026-04-12T21:44:13.709Z" },
+ { url = "https://files.pythonhosted.org/packages/cc/4d/619866af2840875be408047bf9e70ceafbae6ab50660de7134ed1b25eb86/msgspec-0.21.1-cp312-cp312-win_amd64.whl", hash = "sha256:d4ab834a054c6f0cbeef6df9e7e1b33d5f1bc7b86dea1d2fd7cad003873e783d", size = 190017, upload-time = "2026-04-12T21:44:14.977Z" },
+ { url = "https://files.pythonhosted.org/packages/5e/2e/a8f9eca8fd00e097d7a9e99ba8a4685db994494448e3d4f0b7f6e9a3c0f7/msgspec-0.21.1-cp312-cp312-win_arm64.whl", hash = "sha256:628aaa35c74950a8c59da330d7e98917e1c7188f983745782027748ee4ca573e", size = 175345, upload-time = "2026-04-12T21:44:16.431Z" },
+ { url = "https://files.pythonhosted.org/packages/7e/74/f11ede02839b19ff459f88e3145df5d711626ca84da4e23520cebf819367/msgspec-0.21.1-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:764173717a01743f007e9f74520ed281f24672c604514f7d76c1c3a10e8edb66", size = 196176, upload-time = "2026-04-12T21:44:17.613Z" },
+ { url = "https://files.pythonhosted.org/packages/bb/40/4476c1bd341418a046c4955aff632ec769315d1e3cb94e6acf86d461f9ed/msgspec-0.21.1-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:344c7cd0eaed1fb81d7959f99100ef71ec9b536881a376f11b9a6c4803365697", size = 188524, upload-time = "2026-04-12T21:44:18.815Z" },
+ { url = "https://files.pythonhosted.org/packages/ca/d9/9e9d7d7e5061b47540d03d640fab9b3965ba7ae49c1b2154861c8f007518/msgspec-0.21.1-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:48943e278b3854c2f89f955ddc6f9f430d3f0784b16e47d10604ee0463cd21f5", size = 218880, upload-time = "2026-04-12T21:44:20.028Z" },
+ { url = "https://files.pythonhosted.org/packages/74/66/2bb344f34abb4b57e60c7c9c761994e0417b9718ec1460bf00c296f2a7ea/msgspec-0.21.1-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:a9aa659ebb0101b1cbc31461212b87e341d961f0ab0772aaf068a99e001ec4aa", size = 225050, upload-time = "2026-04-12T21:44:21.577Z" },
+ { url = "https://files.pythonhosted.org/packages/1a/84/7c1e412f76092277bf760cef12b7979d03314d259ab5b5cafde5d0c1722d/msgspec-0.21.1-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:f7b27d1a8ead2b6f5b0c4f2d07b8be1ccfcc041c8a0e704781edebe3ae13c484", size = 222713, upload-time = "2026-04-12T21:44:22.83Z" },
+ { url = "https://files.pythonhosted.org/packages/4e/27/0bba04b2b4ef05f3d068429410bc71d2cea925f1596a8f41152cccd5edb8/msgspec-0.21.1-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:38fe93e86b61328fe544cb7fd871fad5a27c8734bfda90f65e5dbe288ae50f61", size = 227259, upload-time = "2026-04-12T21:44:24.11Z" },
+ { url = "https://files.pythonhosted.org/packages/b0/2d/09574b0eea02fed2c2c1383dbaae2c7f79dc16dcd6487a886000afb5d7c4/msgspec-0.21.1-cp313-cp313-win_amd64.whl", hash = "sha256:8bc666331c35fcce05a7cd2d6221adbe0f6058f8e750711413d22793c080ac6a", size = 189857, upload-time = "2026-04-12T21:44:25.359Z" },
+ { url = "https://files.pythonhosted.org/packages/46/34/105b1576ad182879914f0c821f17ee1d13abb165cb060448f96fe2aff078/msgspec-0.21.1-cp313-cp313-win_arm64.whl", hash = "sha256:42bb1241e0750c1a4346f2aa84db26c5ffd99a4eb3a954927d9f149ff2f42898", size = 175403, upload-time = "2026-04-12T21:44:26.608Z" },
+ { url = "https://files.pythonhosted.org/packages/5a/ad/86954e987d1d6a5c579e2c2e7832b65e0fff194179fdac4f581536086024/msgspec-0.21.1-cp314-cp314-macosx_10_15_x86_64.whl", hash = "sha256:fab48eb45fdbfbdb2c0edfec00ffc53b6b6085beefc6b50b61e01659f9f8757f", size = 196261, upload-time = "2026-04-12T21:44:27.807Z" },
+ { url = "https://files.pythonhosted.org/packages/d1/a1/c5e46c3e42b866199365e35d11dddfd1fbd8bba4fdb3c52f965b1607ce94/msgspec-0.21.1-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:3cb779ea0c35bc807ff941d415875c1f69ca0be91a2e907ab99a171811d86a9a", size = 188729, upload-time = "2026-04-12T21:44:28.99Z" },
+ { url = "https://files.pythonhosted.org/packages/85/7d/1e29a319d678d6cb962ae5bdf32a6858ebdf38f73bc654c0e9c742a0c2c8/msgspec-0.21.1-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:68604db36b3b4dd9bf160e436e12798a4738848144cea1aca1cb984011eb160f", size = 219866, upload-time = "2026-04-12T21:44:31.104Z" },
+ { url = "https://files.pythonhosted.org/packages/25/1f/cca084ca2572810fff12ea9dbdcbe39eac048f40daf4a9077b49fcbe8cee/msgspec-0.21.1-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:3d6b9dc50948eaf65df54d2fd0ff66e6d8c32f116037209ee861810eb9b676cb", size = 224993, upload-time = "2026-04-12T21:44:32.649Z" },
+ { url = "https://files.pythonhosted.org/packages/71/94/d2120fc9d419a89a3a7c13e5b7078798c4b392a96a02a6e2b3ce43a8766c/msgspec-0.21.1-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:52c5e21930942302394429c5a582ce7e6b62c7f983b3760834c2ce107e0dd6df", size = 223535, upload-time = "2026-04-12T21:44:33.839Z" },
+ { url = "https://files.pythonhosted.org/packages/75/17/42418b66a3ad972a89bab73dd78b79cc6282bb488a25e73c853cee7443b9/msgspec-0.21.1-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:abbb39d65681fa24ed394e01af3d59d869068324f900c61d06062b7fb9980f2f", size = 227222, upload-time = "2026-04-12T21:44:35.093Z" },
+ { url = "https://files.pythonhosted.org/packages/c4/33/265c894268cca88ff67b144ca2b4c522fc8b9a6f1966a3640c70516e78e1/msgspec-0.21.1-cp314-cp314-win_amd64.whl", hash = "sha256:5666b1b560b97b6ec2eb3fca8a502298ebac56e13bbca1f88523538ce83d01ea", size = 193810, upload-time = "2026-04-12T21:44:36.612Z" },
+ { url = "https://files.pythonhosted.org/packages/3b/8f/a6d35f25bf1fc63c492fdd88fdce01ba0875ead48c2b91f90f33653b4131/msgspec-0.21.1-cp314-cp314-win_arm64.whl", hash = "sha256:d8b8578e4c83b14ceea4cef0d0b747e31d9330fe4b03b2b2ad4063866a178f93", size = 179125, upload-time = "2026-04-12T21:44:38.198Z" },
+ { url = "https://files.pythonhosted.org/packages/c6/39/74839641e64b99d87da55af0fc472854d42b46e2183b9e2a67fe1bb2a512/msgspec-0.21.1-cp314-cp314t-macosx_10_15_x86_64.whl", hash = "sha256:15f523d51c00ebad412213bfe9f06f0a50ec2b93e0c19e824a2d267cabb48ea2", size = 200171, upload-time = "2026-04-12T21:44:39.414Z" },
+ { url = "https://files.pythonhosted.org/packages/70/9b/ce0cca6d2d87fcd4b6ff97600790494e64f26a2c55d61507cd2755c16193/msgspec-0.21.1-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:4e47390360583ba3d5c6cb44cf0a9f61b0a06a899d3c2c00627cedebb2e2884b", size = 192879, upload-time = "2026-04-12T21:44:40.882Z" },
+ { url = "https://files.pythonhosted.org/packages/a7/08/673a7bb05e5702dc787ddd3011195b509f9867927970da59052211929987/msgspec-0.21.1-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:f60800e6299b798142dc40b0644da77ceac5ea0568be58228417eae14135c847", size = 226281, upload-time = "2026-04-12T21:44:42.181Z" },
+ { url = "https://files.pythonhosted.org/packages/7d/45/86508cf57283e9070b3c447e3ab25b792a7a0855a3ea4e0c6d111ac34c97/msgspec-0.21.1-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:5f8e9dfcd98419cf7568808470c4317a3fb30bef0e3715b568730a2b272a20d7", size = 229863, upload-time = "2026-04-12T21:44:43.442Z" },
+ { url = "https://files.pythonhosted.org/packages/2c/62/e7c9367cd08d590559faacd711edbae36840342843e669440363f33c7d36/msgspec-0.21.1-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:92d89dfad13bd1ea640dc3e37e724ed380da1030b272bdf5ecafb983c3ad7c75", size = 230445, upload-time = "2026-04-12T21:44:44.806Z" },
+ { url = "https://files.pythonhosted.org/packages/42/b4/c0f54632103846b658a10930025f4de41c8724b5e4805a5f3b395586cb7e/msgspec-0.21.1-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:0d03867786e5d7ba25d666df4b11320c27170f4aeafcb8e3a8b0a50a4fb742ca", size = 231822, upload-time = "2026-04-12T21:44:46.343Z" },
+ { url = "https://files.pythonhosted.org/packages/ea/1d/0d85cc79d0ccf5508e9c846cc66552a6a16bf92abd1dbd8362617f7b35cd/msgspec-0.21.1-cp314-cp314t-win_amd64.whl", hash = "sha256:740fbf1c9d59992ca3537d6fbe9ebbf9eaf726a65fbf31448e0ecbc710697a63", size = 206650, upload-time = "2026-04-12T21:44:47.601Z" },
+ { url = "https://files.pythonhosted.org/packages/90/91/56c5d560f20e6c20e9e4f55bd0e458f7f162aa689ee350346c04c48eac0b/msgspec-0.21.1-cp314-cp314t-win_arm64.whl", hash = "sha256:0d2cc73df6058d811a126ac3a8ad63a4dfa210c82f9cf5a004802eaf4712de90", size = 183149, upload-time = "2026-04-12T21:44:48.833Z" },
+]
+
[[package]]
name = "nodeenv"
version = "1.10.0"
@@ -856,7 +904,7 @@ source = { editable = "." }
dependencies = [
{ name = "gpiozero", marker = "sys_platform == 'linux'" },
{ name = "jack-client" },
- { name = "jsonschema" },
+ { name = "msgspec" },
{ name = "numpy" },
{ name = "pillow" },
{ name = "pyalsaaudio", marker = "sys_platform == 'linux'" },
@@ -884,6 +932,7 @@ hardware = [
[package.dev-dependencies]
dev = [
+ { name = "jsonschema" },
{ name = "pillow" },
{ name = "pyright" },
{ name = "pytest" },
@@ -902,9 +951,9 @@ requires-dist = [
{ name = "gfxhat", marker = "sys_platform == 'linux' and extra == 'hardware'", specifier = ">=0.0.1" },
{ name = "gpiozero", marker = "sys_platform == 'linux'", specifier = ">=2.0" },
{ name = "jack-client", specifier = ">=0.5.5" },
- { 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 = "msgspec", specifier = ">=0.21.1" },
{ name = "numpy", specifier = ">=2.4" },
{ name = "pillow", specifier = ">=9.4" },
{ name = "pyalsaaudio", marker = "sys_platform == 'linux'", specifier = ">=0.9" },
@@ -922,6 +971,7 @@ provides-extras = ["hardware"]
[package.metadata.requires-dev]
dev = [
+ { name = "jsonschema", specifier = ">=4.0" },
{ name = "pillow", specifier = ">=12.0.0" },
{ name = "pyright", specifier = ">=1.1.408" },
{ name = "pytest", specifier = ">=9.0.2" },