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" },