Assign different effect slots per rig. The same button always controls the right effect, regardless of how slots are organized on the Kemper.
+
+
+
+
+
+
+
1 · The problem it solves
+
In the Kemper, each rig distributes its effects across slots (A, B, C, D, X, MOD, DLY, REV). With PySwitch, each button controls a specific slot — for example button 1 always controls slot A. The problem arises when different slots hold different types of effects depending on the rig:
+
+
+ Slot A Slot B Slot C Slot X
+Rig 1 ── Compressor Chorus Delay Reverb
+Rig 2 ── Distortion Compressor Chorus Delay
+Rig 3 ── Wah Delay Compressor Reverb
+
+■ Button 1 (Slot A): activates Compressor on Rig 1, but Distortion on Rig 2 and Wah on Rig 3
+
+
+
With EFFECT_STATE_PER_RIG you can specify a default slot and then overrides for specific rigs. When you change rig, the button automatically points to the correct slot, updating the LED and display in real time.
+
+
+ Button 1 Button 2 Button 3 Button 4
+Default ─ Slot A Slot B Slot C Slot X
+Rig 2 ─ Slot B Slot A Slot C Slot X ← override
+Rig 3 ─ Slot C Slot B Slot A Slot X ← override
+
+
+
+
+
+
2 · Device installation (MIDICaptain)
+
You need to copy a single Python file to the Kemper effects folder on the device.
+
+
+
+
+
+
Mount the device as USB
+
Hold button 1 while powering on the MIDICaptain. The MIDICAPTAIN drive will appear on your computer.
Eject the drive safely, then restart the controller.
+
+
+
+
+
+ Warning
+ The file must be in the same folder as the other Kemper action files (effect_state.py, rig_select.py, etc.), otherwise relative imports will fail.
+
+
+
+
+
+
3 · Web editor installation
+
If you use the PySwitch Emulator (online version) you cannot modify it directly. You need to use a local version. If you have already cloned the project to C:\Users\…\Desktop\pyswitch\, the files are already patched. For other installations, use the included script.
+
+
Automatic patch via script
+
Run the Python script from the web_patch/ folder (available in the feature/effect-state-per-rig branch):
+ In the feature/effect-state-per-rig branch
+ If you used the prepared branch, all three web editor changes (actions.json, meta.json, ActionProperties.js) are already included in the repository. No need to run anything — just open the web editor from the local folder.
+
+
+
Opening the local web editor
+
Open web/htdocs/index.html with a local server (required for WASM). With Python:
+
+
cd web/htdocs
+python -m http.server 8080
+
+
Then open http://localhost:8080 in your browser.
+
+
+
+
+
4 · Manual configuration in inputs.py
+
Add the import at the top of the file and replace EFFECT_STATE with EFFECT_STATE_PER_RIG for buttons that require overrides.
+
+
from pyswitch.clients.kemper.actions.effect_state_per_rig importEFFECT_STATE_PER_RIG
+from pyswitch.clients.kemper import KemperEffectSlot
+
+# Absolute rig ID = (bank - 1) * 5 + (rig - 1)
+# Bank 1, Rig 1 → 0 Bank 1, Rig 2 → 1 Bank 1, Rig 3 → 2
+# Bank 2, Rig 1 → 5 Bank 2, Rig 2 → 6 etc.
+
+Inputs = [
+ # Button 1: default Slot A, but uses a different slot for certain rigs
+ {
+ "assignment": PA_MIDICAPTAIN_10_SWITCH_1,
+ "actions": [
+ EFFECT_STATE_PER_RIG(
+ slot_id = KemperEffectSlot.EFFECT_SLOT_ID_A, # default
+ rig_overrides = {
+ 2: KemperEffectSlot.EFFECT_SLOT_ID_C, # Bank 1 Rig 3 → Slot C
+ 5: KemperEffectSlot.EFFECT_SLOT_ID_DLY, # Bank 2 Rig 1 → Slot DLY
+ },
+ display = DISPLAY_HEADER_1
+ ),
+ ],
+ },
+ # Buttons without overrides: keep using normal EFFECT_STATE
+ {
+ "assignment": PA_MIDICAPTAIN_10_SWITCH_2,
+ "actions": [
+ EFFECT_STATE(slot_id = KemperEffectSlot.EFFECT_SLOT_ID_B, display = DISPLAY_HEADER_2),
+ ],
+ },
+ # ...
+]
+
+
+ Available parameters
+ EFFECT_STATE_PER_RIG accepts all parameters of EFFECT_STATE (display, mode, text, color, show_slot_names, use_leds, enable_callback) plus the mandatory rig_overrides parameter.
+
+
+
+
+
+
5 · Configuration via web editor
+
After applying the patches, the new action appears in the Kemper effects list under the name EFFECT_STATE_PER_RIG.
+
+
How to add the action to a button
+
+
+
+
+
In the button configuration panel, click + Add action and search for EFFECT_STATE_PER_RIG in the Effects category.
+
+
+
+
+
+
Select the default Slot from the dropdown (Slot A, B, C, D, X, MOD, DLY, REV, etc.). This slot will be used for all rigs without an override.
+
+
+
+
+
+
In the Rig Overrides section, add exceptions rig by rig.
+
+
+
+
+
Rig Overrides interface
+
+
+ Slot
+ Slot A
+
+
+ Display
+ DISPLAY_HEADER_1
+
+
+ Rig Overrides
+
+
Per-rig overrides: alternate slot for specific bank/rig combinations
+
+
+
+
Bank
+
Rig
+
Slot
+
+
+
+
+
+
1
+
3
+
Slot C
+
✕
+
+
+
2
+
1
+
Slot DLY (spillover)
+
✕
+
+
+
+ + Add override
+
+
+
+
+
Table usage
+
+
Bank: bank number (1 – 125)
+
Rig: rig position within the bank (1 – 5)
+
Slot: the effect slot to use when this rig is active
+
+ Add override: adds a new row to the table
+
✕: removes the corresponding row
+
+
+
+ Code ↔ UI round-trip
+ The table is populated automatically even when loading a configuration from existing Python code. You can freely switch between the graphical editor and the code editor without losing data.
+
+
+
Saving
+
Use the Save → Connected Controllers button (via MIDI) or Save → Download (ZIP) and copy the files to the device via USB as described in the Installation section.
+
+
+
+
+
6 · How to calculate the absolute rig ID
+
The web editor asks for Bank and Rig separately, so no calculation is needed. If you configure inputs.py manually, the rig_overrides dictionary uses absolute IDs calculated with this formula:
+
+
abs_rig_id = (bank - 1) * 5 + (rig - 1)
+
+
Where bank and rig are both 1-based (first bank is 1, first rig is 1).
+ Tip
+ You can use normal EFFECT_STATE for buttons that do not need overrides. Both action types are compatible and can coexist in the same inputs.py.
+
+
+
+
+
+
+
+
+
diff --git a/docs/plans/20260423_010731_ui_completeness.md b/docs/plans/20260423_010731_ui_completeness.md
new file mode 100644
index 00000000..b13be479
--- /dev/null
+++ b/docs/plans/20260423_010731_ui_completeness.md
@@ -0,0 +1,291 @@
+# UI Completeness Plan
+Branch: `feature/ui-completeness`
+Date: 2026-04-23
+
+---
+
+## Overview
+
+The web editor lets users configure PySwitch visually without editing Python files.
+Currently several features that are fully supported in Python are either absent from the
+editor or only partially exposed. This plan lists all gaps, what each one enables for the
+user, and a rough implementation note.
+
+Items are grouped by priority: **critical** (blocks common use cases), **moderate**
+(limits power users), **minor** (edge cases / advanced).
+
+---
+
+## ~~Priority 1 — NOT a gap~~
+
+### ~~1. Switch hold actions~~ — ALREADY IMPLEMENTED
+
+Hold actions, `holdTimeMillis`, and `holdRepeat` are fully supported in the editor:
+- `ParserFrontendInput.js` renders a separate "hold" slot for each switch
+- `ActionProperties.js` has a "hold" checkbox in the action dialog
+- `InputSettings.js` exposes `holdTimeMillis` and `holdRepeat` via the wrench button
+
+---
+
+## Priority 1 — Critical
+
+### 1. Paging system (`PagerAction`, page assignments)
+
+**What it enables:**
+Multi-page layouts. With paging you can assign different sets of actions to the same
+physical switches depending on which "page" is active. This multiplies the number of
+configurable actions without needing more hardware switches.
+Example: page 1 = effect toggles, page 2 = rig selectors, with one dedicated switch
+cycling through pages.
+
+**Python source:** `content/lib/pyswitch/controller/actions/__init__.py` (PagerAction / HoldAction),
+referenced in many example configs under `examples/`
+**UI gap:** PagerAction is not registered in `meta.json` at all. No concept of pages
+exists anywhere in the web editor.
+
+**Implementation notes:**
+- Add `PagerAction` (and `HoldAction` if separate) to `meta.json`
+- The page concept needs a UI representation: a page list in the switch editor, with each
+ page containing its own `actions` array
+- This is the most complex item in this list; it may require changes to the switch
+ editor's data model
+
+---
+
+### 3. `DisplaySplitContainer` in the display editor
+
+**What it enables:**
+A split container divides a display area into two (or more) horizontal or vertical
+sections, each showing different information simultaneously.
+Example: top half shows the rig name, bottom half shows the current effect names.
+Without this in the editor, users who want split displays must edit `display.py` by hand.
+
+**Python source:** `content/lib/pyswitch/ui/elements.py` (DisplaySplitContainer)
+**UI gap:** The display editor only supports `DisplayLabel` elements. There is no way to
+add a `DisplaySplitContainer` or nest labels inside it.
+
+**Implementation notes:**
+- Add a new element type in the display editor: "Split Container"
+- Parameters: `direction` (HORIZONTAL / VERTICAL), `bounds`, and a list of child elements
+- Child elements should themselves be editable inline (recursive panel)
+
+---
+
+### 4. `TunerDisplay` in the display editor
+
+**What it enables:**
+A dedicated tuner display element that shows a chromatic tuner visualization (strobe or
+bar style) when the Kemper tuner mode is active.
+Without this in the editor, users cannot add the tuner to their display layout visually.
+
+**Python source:** `content/lib/pyswitch/ui/TunerDisplay.py` (or similar)
+**UI gap:** TunerDisplay is not available as an element type in the display editor.
+Its specific parameters (`mapping_note`, `mapping_deviance`, `zoom`, `color_in_tune`,
+`color_out_of_tune`, `color_neutral`, `calibration_high`, `calibration_low`) are
+never exposed.
+
+**Implementation notes:**
+- Add "Tuner Display" as a new element type in the display editor
+- Expose the color and calibration parameters as optional fields
+
+---
+
+### 5. `DisplayLabel.callback` parameter
+
+**What it enables:**
+A `callback` attached to a `DisplayLabel` lets you write a Python function that
+dynamically controls what the label shows (color, text) based on any runtime state.
+This is the escape hatch for anything the built-in actions cannot display on their own.
+
+**Python source:** `content/lib/pyswitch/ui/elements.py`, `DisplayLabel.__init__`
+**UI gap:** The display editor never shows a `callback` field for labels.
+
+**Implementation notes:**
+- Add a `callback` field of type `any` (free-form Python expression) to the DisplayLabel
+ parameter panel in the display editor, marked as advanced
+
+---
+
+## Priority 2 — Moderate
+
+### 6. `EncoderAction` missing parameters
+
+**What it enables:**
+`EncoderAction` controls a rotary encoder (e.g. a jog wheel) and sends MIDI values.
+The missing parameters let users:
+- `min_value` / `max_value`: Clamp the range (e.g. send only CC 10-100 not 0-127)
+- `step_width`: How much one encoder click changes the value (e.g. coarse vs. fine control)
+- `preview_reset_mapping`: Reset the preview display when a certain mapping changes
+ (e.g. auto-cancel the pending encoder value when you switch rigs)
+- `convert_value`: Display the raw MIDI number as something human-readable
+ (e.g. `lambda v: str(v) + " BPM"`)
+
+**Python source:** `content/lib/pyswitch/controller/actions/EncoderAction.py`, lines 9-28
+**UI gap:** meta.json only exposes `mapping`. The five parameters above are absent.
+
+**Implementation notes:**
+- Add the five parameters to the `ENCODER` entry in `meta.json`
+- `convert_value` and `preview_reset_mapping` are `any` type (advanced)
+- `min_value`, `max_value`, `step_width` are numeric
+
+---
+
+### 7. `AnalogAction` missing parameters
+
+**What it enables:**
+`AnalogAction` controls expression pedals and other analog inputs.
+The missing parameters let users:
+- `max_value`: Set the MIDI range ceiling (e.g. only use the lower half of the pedal range)
+- `auto_calibrate` / `cal_min_window`: Toggle auto-calibration and its sensitivity.
+ Calibration stretches the pedal's physical range to the full MIDI range; disabling it
+ is needed if the pedal already outputs a full 0-65535 range.
+- `transfer_function`: A custom Python lambda that maps raw hardware values to MIDI values.
+ Needed for non-linear pedal responses (log volume taper, S-curve, etc.)
+- `convert_value`: Human-readable display of the sent value
+
+**Python source:** `content/lib/pyswitch/controller/actions/AnalogAction.py`, lines 8-25
+**UI gap:** meta.json exposes `mapping` and a few basics; the advanced calibration and
+conversion parameters are absent.
+
+**Implementation notes:**
+- Add the missing parameters to the `ANALOG` entry in `meta.json`
+- `transfer_function` and `convert_value` are `any` type (advanced)
+
+---
+
+### 8. `RIG_SELECT` missing parameters (`rig_btn_morph`, `momentary_morph`, `color_callback`, `text_callback`)
+
+**What it enables:**
+- `rig_btn_morph`: When you press the rig button for the *currently active* rig a second
+ time, it toggles the Kemper's internal morph state (no MIDI morph command is sent — it
+ just flips the display so the button acts as a morph indicator + toggle).
+ Allows a single button to select a rig AND control morph without a dedicated morph button.
+- `momentary_morph`: The morph state reverts as soon as you release the button (like a
+ momentary footswitch). Needed for users who have set their Kemper rigs to momentary morph.
+- `color_callback` / `text_callback`: Python lambdas that dynamically set the button LED
+ color and label text based on `(action, bank, rig)`. Needed for setups where bank colors
+ or rig names come from a custom source rather than the default `BANK_COLORS` palette.
+
+**Python source:** `content/lib/pyswitch/clients/kemper/actions/rig_select.py`, lines 15-30
+**UI gap:** meta.json covers `rig`, `bank`, `rig_off`, `bank_off`, `display_mode` but not
+the four parameters above.
+
+**Implementation notes:**
+- `rig_btn_morph` and `momentary_morph`: boolean fields, mark as advanced
+- `color_callback` / `text_callback`: `any` type fields, mark as advanced
+
+---
+
+### 9. `EFFECT_STATE` — `show_slot_names` parameter
+
+**What it enables:**
+When `show_slot_names = True`, the button label shows the Kemper effect *slot* name
+(e.g. "A", "B", "X", "MOD") instead of the loaded effect name.
+Useful in compact layouts where there is no room for long effect names but the slot
+position still needs to be visually identified.
+
+**Python source:** `content/lib/pyswitch/clients/kemper/actions/effect_state.py`, line 11
+**UI gap:** `show_slot_names` is absent from the `EFFECT_STATE` entry in meta.json.
+
+**Implementation notes:**
+- Add a boolean `show_slot_names` parameter to the `EFFECT_STATE` entry in meta.json
+
+---
+
+### 10. `communication.py` — `time_lease_seconds` and other protocol options
+
+**What it enables:**
+`time_lease_seconds` controls how long PySwitch waits for a MIDI response before
+re-requesting it. A shorter lease makes the display react faster to changes; a longer
+lease reduces MIDI traffic on busy setups. Currently this is hardcoded.
+
+**Python source:** `content/lib/pyswitch/clients/kemper/communication.py`
+**UI gap:** The "Communication" settings panel in the web editor does not expose this or
+other protocol-level parameters.
+
+**Implementation notes:**
+- Expose `time_lease_seconds` (and any other non-default protocol options) in the
+ Communication settings panel (CommunicationSettings.js)
+
+---
+
+## Priority 3 — Minor
+
+### 11. `HID_KEYBOARD` parameters
+
+**What it enables:**
+`HID_KEYBOARD` sends USB HID keyboard keystrokes from the MIDICaptain to a connected
+computer. The action is registered in meta.json but with no parameters — so it cannot
+actually do anything from the editor. Adding the keycode parameter lets users bind
+buttons to computer shortcuts (transport controls, DAW shortcuts, etc.)
+
+**UI gap:** `HID_KEYBOARD` entry in meta.json has an empty `arguments` array.
+
+**Implementation notes:**
+- Investigate what parameters the Python implementation accepts, then add them to meta.json
+
+---
+
+### 12. `BINARY_SWITCH` action not in meta.json
+
+**What it enables:**
+`BINARY_SWITCH` sends a MIDI CC or NRPN value that is either fully on or fully off
+(no toggle state tracking). Useful for triggering momentary events, arming/disarming
+loops, or any parameter that does not report its state back over MIDI.
+
+**UI gap:** The action exists in Python but is not listed in meta.json at all.
+
+**Implementation notes:**
+- Register the action in meta.json with its required parameters
+
+---
+
+### 13. `config.py` — `enableMidiBridge`
+
+**What it enables:**
+`enableMidiBridge` controls whether the MIDI bridge (used to flash firmware to the
+MIDICaptain over MIDI) is active. On some setups disabling it prevents conflicts.
+Currently always hardcoded to True.
+
+**UI gap:** Not exposed in the ConfigFile settings panel.
+
+**Implementation notes:**
+- Add a boolean toggle in the config.py settings panel
+
+---
+
+### 14. `config.py` — `excludeMessageTypes` and `debugMapping`
+
+**What it enables:**
+- `excludeMessageTypes`: Drop specific MIDI message types from processing (performance
+ optimization; reduces CPU load if certain MIDI messages are noisy on the bus)
+- `debugMapping`: Log MIDI traffic for a specific mapping to the console (developer
+ tool for diagnosing MIDI communication problems)
+
+Both are developer/advanced options with limited real-world use for typical users.
+
+**UI gap:** Not exposed anywhere in the web editor.
+
+**Implementation notes:**
+- Add both as "Advanced" fields in the config.py settings panel
+
+---
+
+## Implementation Order
+
+Suggested order balancing impact vs. effort:
+
+1. `show_slot_names` for EFFECT_STATE — trivial, one line in meta.json
+2. `rig_btn_morph` / `momentary_morph` for RIG_SELECT — two booleans in meta.json
+3. `color_callback` / `text_callback` for RIG_SELECT — advanced `any` fields in meta.json
+4. EncoderAction missing parameters — meta.json + numeric inputs
+5. AnalogAction missing parameters — meta.json + numeric inputs
+6. Hold actions on switches — requires UI changes in switch editor
+7. `DisplaySplitContainer` in display editor — new element type
+8. `TunerDisplay` in display editor — new element type
+9. `DisplayLabel.callback` — one advanced field in display editor
+10. `BINARY_SWITCH` in meta.json — needs Python investigation first
+11. `HID_KEYBOARD` parameters — needs Python investigation first
+12. `enableMidiBridge`, `excludeMessageTypes`, `debugMapping` — config panel additions
+13. `communication.py` options — communication panel additions
+14. Paging system — requires architectural changes (largest item, save for last)
diff --git a/docs/plans/20260425_142646_effect_state_per_rig_config.md b/docs/plans/20260425_142646_effect_state_per_rig_config.md
new file mode 100644
index 00000000..ca8e7b63
--- /dev/null
+++ b/docs/plans/20260425_142646_effect_state_per_rig_config.md
@@ -0,0 +1,57 @@
+# Piano: Configurazione EFFECT_STATE_PER_RIG per 5 switch
+
+**Data**: 2026-04-25
+**Branch**: feature/effect-state-per-rig
+**Dispositivo**: MIDICaptain 10
+
+## Obiettivo
+
+Riconfigurare `content/inputs.py` e `content/display.py` da zero per gestire
+5 switch con `EFFECT_STATE_PER_RIG`:
+
+- Switch 1 e 2: sempre disabilitati (nessuna azione)
+- Switch 3, 4, 5: disabilitati di default (`slot_id=None`), attivi solo per
+ i rig specificati
+
+## Mapping switch → display
+
+| Switch HW | Label utente | Display slot |
+|------------------------|--------------|--------------------|
+| PA_MIDICAPTAIN_10_SWITCH_1 | Switch 1 | nessuno (disabilitato) |
+| PA_MIDICAPTAIN_10_SWITCH_2 | Switch 2 | nessuno (disabilitato) |
+| PA_MIDICAPTAIN_10_SWITCH_3 | Switch 3 | DISPLAY_FOOTER_1 |
+| PA_MIDICAPTAIN_10_SWITCH_4 | Switch 4 | DISPLAY_FOOTER_2 |
+| PA_MIDICAPTAIN_10_SWITCH_UP | Switch 5 | nessuno |
+
+## Mapping rig → slot per switch
+
+Rig ID assoluto = (bank-1)*5 + (rig-1), banco 1 rig 1-5 → ID 0-4.
+
+| Rig | Nome | Switch 3 | Switch 4 | Switch 5 |
+|-----|-------|-------------------|-----------------------|-----------------------|
+| 0 | acou | disabilitato | [MOD + C] insieme | DLY |
+| 1 | clen | X | disabilitato | [DLY + REV] insieme |
+| 2 | crnc | X | disabilitato | [DLY + REV] insieme |
+| 3 | heavy | disabilitato | X | [DLY + REV] insieme |
+| 4 | lead | X | disabilitato | disabilitato |
+
+## Slot Kemper
+
+- `EFFECT_SLOT_ID_X` = 4 (X)
+- `EFFECT_SLOT_ID_MOD` = 5 (MOD)
+- `EFFECT_SLOT_ID_C` = 2 (C)
+- `EFFECT_SLOT_ID_DLY` = 6 (DLY)
+- `EFFECT_SLOT_ID_REV` = 7 (REV)
+
+## File da creare / modificare
+
+1. `content/inputs.py` — configurazione switch (da zero)
+2. `content/display.py` — layout display semplificato (da zero)
+3. `test/pyswitch/test_kemper_action_effect_state_per_rig.py` — test unitari
+
+## Stato
+
+- [x] display.py
+- [x] inputs.py
+- [x] test file (26 test)
+- [x] test verdi (363/363 OK)
diff --git a/test/pyswitch/test_input_controller_switch.py b/test/pyswitch/test_input_controller_switch.py
index 70bfe62f..60376d43 100644
--- a/test/pyswitch/test_input_controller_switch.py
+++ b/test/pyswitch/test_input_controller_switch.py
@@ -249,13 +249,13 @@ def test_default_color_and_brightness(self):
})
self.assertEqual(fs.color, Colors.WHITE)
- self.assertEqual(fs.brightness, 0.5)
+ self.assertEqual(fs.brightness, 0)
for c in fs.colors:
self.assertEqual(c, Colors.WHITE)
self.assertEqual(len(fs.colors), 2)
- self.assertEqual(fs.brightnesses, [0.5, 0.5])
+ self.assertEqual(fs.brightnesses, [0, 0])
##############################################################################
diff --git a/test/pyswitch/test_kemper_action_effect_state.py b/test/pyswitch/test_kemper_action_effect_state.py
index e1251a2c..c8889061 100644
--- a/test/pyswitch/test_kemper_action_effect_state.py
+++ b/test/pyswitch/test_kemper_action_effect_state.py
@@ -59,91 +59,116 @@ def test_effect_state(self):
self.assertEqual(action._Action__enable_callback, ecb)
self.assertEqual(action._PushButtonAction__mode, PushButtonAction.LATCH)
-
def test_effect_categories(self):
cb = KemperEffectEnableCallback(KemperEffectSlot.EFFECT_SLOT_ID_DLY)
# None
self.assertEqual(cb.get_effect_category(0), KemperEffectEnableCallback.CATEGORY_NONE)
- # Wah (and some pitch)
- for i in range(1, 10):
+ # Wah family: 1-10, 12, 13
+ for i in range(1, 11):
self.assertEqual(cb.get_effect_category(i), KemperEffectEnableCallback.CATEGORY_WAH)
+ self.assertEqual(cb.get_effect_category(12), KemperEffectEnableCallback.CATEGORY_WAH)
+ self.assertEqual(cb.get_effect_category(13), KemperEffectEnableCallback.CATEGORY_WAH)
+ # Pitch Pedal: 11
self.assertEqual(cb.get_effect_category(11), KemperEffectEnableCallback.CATEGORY_PITCH)
- self.assertEqual(cb.get_effect_category(12), KemperEffectEnableCallback.CATEGORY_WAH)
- self.assertEqual(cb.get_effect_category(13), KemperEffectEnableCallback.CATEGORY_PITCH)
- # Dist
- for i in range(17, 42):
+ # Distortion / Shaper: 17-42 (Includes Kemper Drive, Fuzz, etc.)
+ for i in range(17, 43):
self.assertEqual(cb.get_effect_category(i), KemperEffectEnableCallback.CATEGORY_DISTORTION)
- # Comp
- for i in range(49, 56):
+ # Dynamics / Compressor: 49-50
+ for i in range(49, 51):
self.assertEqual(cb.get_effect_category(i), KemperEffectEnableCallback.CATEGORY_COMPRESSOR)
- # Noise Gate
- for i in range(57, 58):
+ # Noise Gate: 57-58
+ for i in range(57, 59):
self.assertEqual(cb.get_effect_category(i), KemperEffectEnableCallback.CATEGORY_NOISE_GATE)
- # Space
+ # Space: 64
self.assertEqual(cb.get_effect_category(64), KemperEffectEnableCallback.CATEGORY_SPACE)
- # Chorus
- for i in range(65, 71):
+ # Chorus: 65-67, 71
+ for i in (65, 66, 67, 71):
self.assertEqual(cb.get_effect_category(i), KemperEffectEnableCallback.CATEGORY_CHORUS)
- # Phaser / Flanger
- for i in range(81, 91):
+ # Vibrato: 68
+ self.assertEqual(cb.get_effect_category(68), KemperEffectEnableCallback.CATEGORY_VIBRATO)
+
+ # Rotary: 69
+ self.assertEqual(cb.get_effect_category(69), KemperEffectEnableCallback.CATEGORY_ROTARY)
+
+ # Tremolo: 70, 75, 76
+ for i in (70, 75, 76):
+ self.assertEqual(cb.get_effect_category(i), KemperEffectEnableCallback.CATEGORY_TREMOLO)
+
+ # Slicer / Autopanner: 77-80
+ for i in range(77, 81):
+ self.assertEqual(cb.get_effect_category(i), KemperEffectEnableCallback.CATEGORY_SLICER)
+
+ # Phaser / Flanger: 81-91
+ for i in range(81, 92):
self.assertEqual(cb.get_effect_category(i), KemperEffectEnableCallback.CATEGORY_PHASER_FLANGER)
- # EQ
- for i in range(97, 104):
+ # EQ / Widener: 97-104
+ for i in range(97, 105):
self.assertEqual(cb.get_effect_category(i), KemperEffectEnableCallback.CATEGORY_EQUALIZER)
- # Boost
- for i in range(113, 116):
+ # Booster: 113-116
+ for i in range(113, 117):
self.assertEqual(cb.get_effect_category(i), KemperEffectEnableCallback.CATEGORY_BOOSTER)
- # Looper
- for i in range(121, 123):
+ # Looper: 121-123
+ for i in range(121, 124):
self.assertEqual(cb.get_effect_category(i), KemperEffectEnableCallback.CATEGORY_LOOPER)
- # Pitch
- for i in range(129, 132):
+ # Pitch / Harmony: 129-132
+ for i in range(129, 133):
self.assertEqual(cb.get_effect_category(i), KemperEffectEnableCallback.CATEGORY_PITCH)
- # Dual
- for i in range(137, 140):
+ # Dual / Pitch+Delay: 138-140
+ for i in range(138, 141):
self.assertEqual(cb.get_effect_category(i), KemperEffectEnableCallback.CATEGORY_DUAL)
- # Delay
- for i in range(145, 166):
+ # Delay: 145-166
+ for i in range(145, 167):
self.assertEqual(cb.get_effect_category(i), KemperEffectEnableCallback.CATEGORY_DELAY)
- # Rev
- for i in range(177, 193):
+ # Reverb: 177-193
+ for i in range(177, 194):
self.assertEqual(cb.get_effect_category(i), KemperEffectEnableCallback.CATEGORY_REVERB)
-
def test_type_colors(self):
- # All types have to be mapped
+ # All categories must have a valid color mapped in CATEGORY_COLORS
cb = KemperEffectEnableCallback(KemperEffectSlot.EFFECT_SLOT_ID_DLY)
- cb.get_effect_category_color(KemperEffectEnableCallback.CATEGORY_WAH, 0)
- cb.get_effect_category_color(KemperEffectEnableCallback.CATEGORY_DISTORTION, 0)
- cb.get_effect_category_color(KemperEffectEnableCallback.CATEGORY_COMPRESSOR, 0)
- cb.get_effect_category_color(KemperEffectEnableCallback.CATEGORY_NOISE_GATE, 0)
- cb.get_effect_category_color(KemperEffectEnableCallback.CATEGORY_SPACE, 0)
- cb.get_effect_category_color(KemperEffectEnableCallback.CATEGORY_CHORUS, 0)
- cb.get_effect_category_color(KemperEffectEnableCallback.CATEGORY_PHASER_FLANGER, 0)
- cb.get_effect_category_color(KemperEffectEnableCallback.CATEGORY_EQUALIZER, 0)
- cb.get_effect_category_color(KemperEffectEnableCallback.CATEGORY_BOOSTER, 0)
- cb.get_effect_category_color(KemperEffectEnableCallback.CATEGORY_LOOPER, 0)
- cb.get_effect_category_color(KemperEffectEnableCallback.CATEGORY_PITCH, 0)
- cb.get_effect_category_color(KemperEffectEnableCallback.CATEGORY_DUAL, 0)
- cb.get_effect_category_color(KemperEffectEnableCallback.CATEGORY_DELAY, 0)
- cb.get_effect_category_color(KemperEffectEnableCallback.CATEGORY_REVERB, 0)
+ categories = [
+ KemperEffectEnableCallback.CATEGORY_WAH,
+ KemperEffectEnableCallback.CATEGORY_DISTORTION,
+ KemperEffectEnableCallback.CATEGORY_COMPRESSOR,
+ KemperEffectEnableCallback.CATEGORY_NOISE_GATE,
+ KemperEffectEnableCallback.CATEGORY_SPACE,
+ KemperEffectEnableCallback.CATEGORY_CHORUS,
+ KemperEffectEnableCallback.CATEGORY_PHASER_FLANGER,
+ KemperEffectEnableCallback.CATEGORY_EQUALIZER,
+ KemperEffectEnableCallback.CATEGORY_BOOSTER,
+ KemperEffectEnableCallback.CATEGORY_LOOPER,
+ KemperEffectEnableCallback.CATEGORY_PITCH,
+ KemperEffectEnableCallback.CATEGORY_DUAL,
+ KemperEffectEnableCallback.CATEGORY_DELAY,
+ KemperEffectEnableCallback.CATEGORY_REVERB,
+ # New categories from your latest effect_state.py
+ KemperEffectEnableCallback.CATEGORY_TREMOLO,
+ KemperEffectEnableCallback.CATEGORY_ROTARY,
+ KemperEffectEnableCallback.CATEGORY_VIBRATO,
+ KemperEffectEnableCallback.CATEGORY_SLICER
+ ]
+
+ for cat in categories:
+ color = cb.get_effect_category_color(cat, 0)
+ # Ensure the color returned is not None
+ self.assertIsNotNone(color)
def test_color_override(self):
cb = KemperEffectEnableCallback(
@@ -190,19 +215,32 @@ def test_type_names(self):
# All types have to be mapped
cb = KemperEffectEnableCallback(KemperEffectSlot.EFFECT_SLOT_ID_DLY)
- cb.get_effect_category_text(KemperEffectEnableCallback.CATEGORY_WAH, 0)
- cb.get_effect_category_text(KemperEffectEnableCallback.CATEGORY_DISTORTION, 0)
- cb.get_effect_category_text(KemperEffectEnableCallback.CATEGORY_COMPRESSOR, 0)
- cb.get_effect_category_text(KemperEffectEnableCallback.CATEGORY_NOISE_GATE, 0)
- cb.get_effect_category_text(KemperEffectEnableCallback.CATEGORY_SPACE, 0)
- cb.get_effect_category_text(KemperEffectEnableCallback.CATEGORY_CHORUS, 0)
- cb.get_effect_category_text(KemperEffectEnableCallback.CATEGORY_PHASER_FLANGER, 0)
- cb.get_effect_category_text(KemperEffectEnableCallback.CATEGORY_EQUALIZER, 0)
- cb.get_effect_category_text(KemperEffectEnableCallback.CATEGORY_BOOSTER, 0)
- cb.get_effect_category_text(KemperEffectEnableCallback.CATEGORY_LOOPER, 0)
- cb.get_effect_category_text(KemperEffectEnableCallback.CATEGORY_PITCH, 0)
- cb.get_effect_category_text(KemperEffectEnableCallback.CATEGORY_DUAL, 0)
- cb.get_effect_category_text(KemperEffectEnableCallback.CATEGORY_DELAY, 0)
- cb.get_effect_category_text(KemperEffectEnableCallback.CATEGORY_REVERB, 0)
-
+ # List of all categories defined in KemperEffectEnableCallback
+ categories = [
+ KemperEffectEnableCallback.CATEGORY_WAH,
+ KemperEffectEnableCallback.CATEGORY_DISTORTION,
+ KemperEffectEnableCallback.CATEGORY_COMPRESSOR,
+ KemperEffectEnableCallback.CATEGORY_NOISE_GATE,
+ KemperEffectEnableCallback.CATEGORY_SPACE,
+ KemperEffectEnableCallback.CATEGORY_CHORUS,
+ KemperEffectEnableCallback.CATEGORY_PHASER_FLANGER,
+ KemperEffectEnableCallback.CATEGORY_EQUALIZER,
+ KemperEffectEnableCallback.CATEGORY_BOOSTER,
+ KemperEffectEnableCallback.CATEGORY_LOOPER,
+ KemperEffectEnableCallback.CATEGORY_PITCH,
+ KemperEffectEnableCallback.CATEGORY_DUAL,
+ KemperEffectEnableCallback.CATEGORY_DELAY,
+ KemperEffectEnableCallback.CATEGORY_REVERB,
+ # New categories added in PR
+ KemperEffectEnableCallback.CATEGORY_TREMOLO,
+ KemperEffectEnableCallback.CATEGORY_ROTARY,
+ KemperEffectEnableCallback.CATEGORY_VIBRATO,
+ KemperEffectEnableCallback.CATEGORY_SLICER
+ ]
+
+ for category in categories:
+ name = cb.get_effect_category_text(category, 0)
+ # Ensure the name is not the default "-" unless it's CATEGORY_NONE
+ self.assertIsNotNone(name)
+ self.assertNotEqual(name, "")
diff --git a/test/pyswitch/test_kemper_action_effect_state_per_rig.py b/test/pyswitch/test_kemper_action_effect_state_per_rig.py
new file mode 100644
index 00000000..32e4a6fc
--- /dev/null
+++ b/test/pyswitch/test_kemper_action_effect_state_per_rig.py
@@ -0,0 +1,389 @@
+import sys
+import unittest
+from unittest.mock import patch
+
+from .mocks_lib import *
+
+with patch.dict(sys.modules, {
+ "micropython": MockMicropython,
+ "displayio": MockDisplayIO(),
+ "adafruit_display_text": MockAdafruitDisplayText(),
+ "adafruit_midi.control_change": MockAdafruitMIDIControlChange(),
+ "adafruit_midi.system_exclusive": MockAdafruitMIDISystemExclusive(),
+ "adafruit_midi.midi_message": MockAdafruitMIDIMessage(),
+ "adafruit_midi.program_change": MockAdafruitMIDIProgramChange(),
+ "adafruit_display_shapes.rect": MockDisplayShapes().rect(),
+ "gc": MockGC()
+}):
+ from lib.pyswitch.clients.kemper import KemperEffectSlot
+ from lib.pyswitch.clients.kemper.actions.effect_state_per_rig import (
+ EFFECT_STATE_PER_RIG,
+ KemperEffectEnablePerRigCallback,
+ )
+ from lib.pyswitch.controller.actions import PushButtonAction
+
+ from .mocks_appl import MockClient
+
+
+# Absolute rig IDs (bank 1, rigs 1-5)
+_RIG_ACOU = 0 # rig 1 "acou"
+_RIG_CLEN = 1 # rig 2 "clen"
+_RIG_CRNC = 2 # rig 3 "crnc"
+_RIG_HEAVY = 3 # rig 4 "heavy"
+_RIG_LEAD = 4 # rig 5 "lead"
+
+X = KemperEffectSlot.EFFECT_SLOT_ID_X
+MOD = KemperEffectSlot.EFFECT_SLOT_ID_MOD
+C = KemperEffectSlot.EFFECT_SLOT_ID_C
+DLY = KemperEffectSlot.EFFECT_SLOT_ID_DLY
+REV = KemperEffectSlot.EFFECT_SLOT_ID_REV
+
+
+class _MockAppl:
+ """Minimal mock application for callback init."""
+ config = {}
+
+ def __init__(self):
+ self.client = MockClient()
+
+ def add_updateable(self, _):
+ pass
+
+
+def _sw3_cb():
+ """Switch 3: X slot for rigs clen, crnc, lead; disabled otherwise."""
+ return KemperEffectEnablePerRigCallback(
+ slot_id=None,
+ rig_overrides={
+ _RIG_CLEN: X,
+ _RIG_CRNC: X,
+ _RIG_LEAD: X,
+ }
+ )
+
+
+def _sw4_cb():
+ """Switch 4: [MOD+C] for acou; X for heavy; disabled otherwise."""
+ return KemperEffectEnablePerRigCallback(
+ slot_id=None,
+ rig_overrides={
+ _RIG_ACOU: [MOD, C],
+ _RIG_HEAVY: X,
+ }
+ )
+
+
+def _sw5_cb():
+ """Switch 5: DLY for acou; [DLY+REV] for clen, crnc, heavy; disabled for lead."""
+ return KemperEffectEnablePerRigCallback(
+ slot_id=None,
+ rig_overrides={
+ _RIG_ACOU: DLY,
+ _RIG_CLEN: [DLY, REV],
+ _RIG_CRNC: [DLY, REV],
+ _RIG_HEAVY: [DLY, REV],
+ }
+ )
+
+
+##############################################################################
+# Tests for EFFECT_STATE_PER_RIG factory
+##############################################################################
+
+class TestEffectStatePerRigFactory(unittest.TestCase):
+
+ def test_factory_returns_push_button_action(self):
+ action = EFFECT_STATE_PER_RIG(
+ slot_id=None,
+ rig_overrides={_RIG_ACOU: X}
+ )
+ self.assertIsInstance(action, PushButtonAction)
+ self.assertIsInstance(action.callback, KemperEffectEnablePerRigCallback)
+
+
+##############################################################################
+# Switch 3: flanger (X slot) — rigs clen, crnc, lead
+##############################################################################
+
+class TestSwitch3CurrentSlots(unittest.TestCase):
+
+ def setUp(self):
+ self.cb = _sw3_cb()
+
+ def test_rig_acou_disabled(self):
+ self.cb._rig_id_mapping.value = _RIG_ACOU
+ self.assertIsNone(self.cb._current_slots())
+
+ def test_rig_clen_x(self):
+ self.cb._rig_id_mapping.value = _RIG_CLEN
+ self.assertEqual(self.cb._current_slots(), [X])
+
+ def test_rig_crnc_x(self):
+ self.cb._rig_id_mapping.value = _RIG_CRNC
+ self.assertEqual(self.cb._current_slots(), [X])
+
+ def test_rig_heavy_disabled(self):
+ self.cb._rig_id_mapping.value = _RIG_HEAVY
+ self.assertIsNone(self.cb._current_slots())
+
+ def test_rig_lead_x(self):
+ self.cb._rig_id_mapping.value = _RIG_LEAD
+ self.assertEqual(self.cb._current_slots(), [X])
+
+ def test_rig_none_disabled(self):
+ self.cb._rig_id_mapping.value = None
+ self.assertIsNone(self.cb._current_slots())
+
+ def test_unknown_rig_disabled(self):
+ # Any rig not in rig_overrides must be disabled (slot_id=None default).
+ self.cb._rig_id_mapping.value = 99
+ self.assertIsNone(self.cb._current_slots())
+
+
+##############################################################################
+# Switch 4: [MOD+C] for acou, X for heavy
+##############################################################################
+
+class TestSwitch4CurrentSlots(unittest.TestCase):
+
+ def setUp(self):
+ self.cb = _sw4_cb()
+
+ def test_rig_acou_mod_and_c(self):
+ self.cb._rig_id_mapping.value = _RIG_ACOU
+ self.assertEqual(self.cb._current_slots(), [MOD, C])
+
+ def test_rig_clen_disabled(self):
+ self.cb._rig_id_mapping.value = _RIG_CLEN
+ self.assertIsNone(self.cb._current_slots())
+
+ def test_rig_crnc_disabled(self):
+ self.cb._rig_id_mapping.value = _RIG_CRNC
+ self.assertIsNone(self.cb._current_slots())
+
+ def test_rig_heavy_x(self):
+ self.cb._rig_id_mapping.value = _RIG_HEAVY
+ self.assertEqual(self.cb._current_slots(), [X])
+
+ def test_rig_lead_disabled(self):
+ self.cb._rig_id_mapping.value = _RIG_LEAD
+ self.assertIsNone(self.cb._current_slots())
+
+
+##############################################################################
+# Switch 5: DLY for acou, [DLY+REV] for clen/crnc/heavy, disabled for lead
+##############################################################################
+
+class TestSwitch5CurrentSlots(unittest.TestCase):
+
+ def setUp(self):
+ self.cb = _sw5_cb()
+
+ def test_rig_acou_dly(self):
+ self.cb._rig_id_mapping.value = _RIG_ACOU
+ self.assertEqual(self.cb._current_slots(), [DLY])
+
+ def test_rig_clen_dly_and_rev(self):
+ self.cb._rig_id_mapping.value = _RIG_CLEN
+ self.assertEqual(self.cb._current_slots(), [DLY, REV])
+
+ def test_rig_crnc_dly_and_rev(self):
+ self.cb._rig_id_mapping.value = _RIG_CRNC
+ self.assertEqual(self.cb._current_slots(), [DLY, REV])
+
+ def test_rig_heavy_dly_and_rev(self):
+ self.cb._rig_id_mapping.value = _RIG_HEAVY
+ self.assertEqual(self.cb._current_slots(), [DLY, REV])
+
+ def test_rig_lead_disabled(self):
+ self.cb._rig_id_mapping.value = _RIG_LEAD
+ self.assertIsNone(self.cb._current_slots())
+
+
+##############################################################################
+# state_changed_by_user — multi-slot AND logic
+##############################################################################
+
+class TestMultiSlotToggle(unittest.TestCase):
+ """Test the AND-logic toggle for multi-slot buttons."""
+
+ def _init_cb(self, cb):
+ appl = _MockAppl()
+ cb.init(appl)
+ return appl
+
+ # ---- Switch 4, rig acou: [MOD + C] ----------------------------------- #
+
+ def test_sw4_acou_all_on_turns_all_off(self):
+ cb = _sw4_cb()
+ appl = self._init_cb(cb)
+ cb._rig_id_mapping.value = _RIG_ACOU
+
+ # Both ON → pressing turns both OFF
+ cb._state_map(MOD).value = 1
+ cb._state_map(C).value = 1
+ appl.client.set_calls.clear()
+
+ cb.state_changed_by_user()
+
+ sent = {c["mapping"]: c["value"] for c in appl.client.set_calls}
+ self.assertEqual(sent[cb._state_map(MOD)], 0)
+ self.assertEqual(sent[cb._state_map(C)], 0)
+
+ def test_sw4_acou_not_all_on_turns_all_on(self):
+ cb = _sw4_cb()
+ appl = self._init_cb(cb)
+ cb._rig_id_mapping.value = _RIG_ACOU
+
+ # MOD ON, C OFF → pressing turns both ON
+ cb._state_map(MOD).value = 1
+ cb._state_map(C).value = 0
+ appl.client.set_calls.clear()
+
+ cb.state_changed_by_user()
+
+ sent = {c["mapping"]: c["value"] for c in appl.client.set_calls}
+ self.assertEqual(sent[cb._state_map(MOD)], 1)
+ self.assertEqual(sent[cb._state_map(C)], 1)
+
+ def test_sw4_acou_both_off_turns_all_on(self):
+ cb = _sw4_cb()
+ appl = self._init_cb(cb)
+ cb._rig_id_mapping.value = _RIG_ACOU
+
+ cb._state_map(MOD).value = 0
+ cb._state_map(C).value = 0
+ appl.client.set_calls.clear()
+
+ cb.state_changed_by_user()
+
+ sent = {c["mapping"]: c["value"] for c in appl.client.set_calls}
+ self.assertEqual(sent[cb._state_map(MOD)], 1)
+ self.assertEqual(sent[cb._state_map(C)], 1)
+
+ # ---- Switch 5, rig clen: [DLY + REV] --------------------------------- #
+
+ def test_sw5_clen_all_on_turns_all_off(self):
+ cb = _sw5_cb()
+ appl = self._init_cb(cb)
+ cb._rig_id_mapping.value = _RIG_CLEN
+
+ cb._state_map(DLY).value = 1
+ cb._state_map(REV).value = 1
+ appl.client.set_calls.clear()
+
+ cb.state_changed_by_user()
+
+ sent = {c["mapping"]: c["value"] for c in appl.client.set_calls}
+ self.assertEqual(sent[cb._state_map(DLY)], 0)
+ self.assertEqual(sent[cb._state_map(REV)], 0)
+
+ def test_sw5_clen_not_all_on_turns_all_on(self):
+ cb = _sw5_cb()
+ appl = self._init_cb(cb)
+ cb._rig_id_mapping.value = _RIG_CLEN
+
+ cb._state_map(DLY).value = 0
+ cb._state_map(REV).value = 1
+ appl.client.set_calls.clear()
+
+ cb.state_changed_by_user()
+
+ sent = {c["mapping"]: c["value"] for c in appl.client.set_calls}
+ self.assertEqual(sent[cb._state_map(DLY)], 1)
+ self.assertEqual(sent[cb._state_map(REV)], 1)
+
+ # ---- Disabled rig: pressing does nothing ----------------------------- #
+
+ def test_sw3_disabled_rig_does_nothing(self):
+ cb = _sw3_cb()
+ appl = self._init_cb(cb)
+ cb._rig_id_mapping.value = _RIG_ACOU # acou is disabled on switch 3
+ appl.client.set_calls.clear()
+
+ cb.state_changed_by_user()
+
+ self.assertEqual(appl.client.set_calls, [])
+
+ def test_sw4_disabled_rig_does_nothing(self):
+ cb = _sw4_cb()
+ appl = self._init_cb(cb)
+ cb._rig_id_mapping.value = _RIG_CLEN # clen is disabled on switch 4
+ appl.client.set_calls.clear()
+
+ cb.state_changed_by_user()
+
+ self.assertEqual(appl.client.set_calls, [])
+
+ def test_sw5_disabled_rig_does_nothing(self):
+ cb = _sw5_cb()
+ appl = self._init_cb(cb)
+ cb._rig_id_mapping.value = _RIG_LEAD # lead is disabled on switch 5
+ appl.client.set_calls.clear()
+
+ cb.state_changed_by_user()
+
+ self.assertEqual(appl.client.set_calls, [])
+
+
+##############################################################################
+# Regression: string keys in rig_overrides must be normalized to int
+##############################################################################
+
+class TestRigOverrideKeyNormalization(unittest.TestCase):
+ """Verify that rig_overrides accepts both int and string keys.
+
+ The RIG_ID mapping always returns an int. String keys like "0", "1"
+ must be normalized to int in __init__ so _current_slots() can look them
+ up correctly. See issue #1 (Git commit: fix-string-keys-in-rig-overrides).
+ """
+
+ def test_int_key_matches(self):
+ cb = KemperEffectEnablePerRigCallback(
+ slot_id=None,
+ rig_overrides={0: X}
+ )
+ cb._rig_id_mapping.value = 0
+ self.assertEqual(cb._current_slots(), [X])
+
+ def test_string_key_matches_after_normalization(self):
+ cb = KemperEffectEnablePerRigCallback(
+ slot_id=None,
+ rig_overrides={"0": X}
+ )
+ cb._rig_id_mapping.value = 0
+ self.assertEqual(cb._current_slots(), [X])
+
+ def test_mixed_string_and_int_keys(self):
+ cb = KemperEffectEnablePerRigCallback(
+ slot_id=None,
+ rig_overrides={
+ 0: DLY,
+ "1": [DLY, REV],
+ "2": [DLY, REV],
+ }
+ )
+ cb._rig_id_mapping.value = 0
+ self.assertEqual(cb._current_slots(), [DLY])
+
+ cb._rig_id_mapping.value = 1
+ self.assertEqual(cb._current_slots(), [DLY, REV])
+
+ cb._rig_id_mapping.value = 2
+ self.assertEqual(cb._current_slots(), [DLY, REV])
+
+ def test_rig_not_in_overrides_is_still_disabled(self):
+ cb = KemperEffectEnablePerRigCallback(
+ slot_id=None,
+ rig_overrides={"0": X}
+ )
+ cb._rig_id_mapping.value = 99
+ self.assertIsNone(cb._current_slots())
+
+ def test_none_rig_id_is_still_disabled(self):
+ cb = KemperEffectEnablePerRigCallback(
+ slot_id=None,
+ rig_overrides={"0": X}
+ )
+ cb._rig_id_mapping.value = None
+ self.assertIsNone(cb._current_slots())
diff --git a/web/htdocs/definitions/actions.json b/web/htdocs/definitions/actions.json
index 00d35fba..d1a324c1 100644
--- a/web/htdocs/definitions/actions.json
+++ b/web/htdocs/definitions/actions.json
@@ -1573,6 +1573,63 @@
"comment": "Switch an effect slot on / off. This variant has distinct names for each effect type. \n\nUse with care: This takes quite some RAM memory, so if you run a large configuration you might run into memory allocation failures. In this case, just use the normal Effect State action instead.",
"importPath": "pyswitch.clients.kemper.actions.effect_state_extended_names"
},
+ {
+ "name": "EFFECT_STATE_PER_RIG",
+ "parameters": [
+ {
+ "name": "slot_id",
+ "default": null,
+ "comment": null
+ },
+ {
+ "name": "rig_overrides",
+ "default": "{}",
+ "comment": null
+ },
+ {
+ "name": "display",
+ "default": "None",
+ "comment": null
+ },
+ {
+ "name": "mode",
+ "default": "PushButtonAction.HOLD_MOMENTARY",
+ "comment": null
+ },
+ {
+ "name": "show_slot_names",
+ "default": "False",
+ "comment": null
+ },
+ {
+ "name": "id",
+ "default": "False",
+ "comment": null
+ },
+ {
+ "name": "text",
+ "default": "None",
+ "comment": null
+ },
+ {
+ "name": "color",
+ "default": "None",
+ "comment": null
+ },
+ {
+ "name": "use_leds",
+ "default": "True",
+ "comment": null
+ },
+ {
+ "name": "enable_callback",
+ "default": "None",
+ "comment": null
+ }
+ ],
+ "comment": "Switch an effect slot on / off, with per-rig slot override",
+ "importPath": "pyswitch.clients.kemper.actions.effect_state_per_rig"
+ },
{
"name": "RIG_SELECT_AND_MORPH_STATE",
"parameters": [
diff --git a/web/htdocs/definitions/meta.json b/web/htdocs/definitions/meta.json
index ff3f7214..5625085d 100644
--- a/web/htdocs/definitions/meta.json
+++ b/web/htdocs/definitions/meta.json
@@ -394,6 +394,70 @@
"target": "AdafruitSwitch",
"parameters": []
},
+ {
+ "entityName": "EFFECT_STATE_PER_RIG",
+ "category": "effects",
+ "target": "AdafruitSwitch",
+ "parameters": [
+ {
+ "name": "slot_id",
+ "type": "select",
+ "values": [
+ {
+ "name": "None (disabled by default)",
+ "value": "None"
+ },
+ {
+ "name": "Slot A",
+ "value": "KemperEffectSlot.EFFECT_SLOT_ID_A"
+ },
+ {
+ "name": "Slot B",
+ "value": "KemperEffectSlot.EFFECT_SLOT_ID_B"
+ },
+ {
+ "name": "Slot C",
+ "value": "KemperEffectSlot.EFFECT_SLOT_ID_C"
+ },
+ {
+ "name": "Slot D",
+ "value": "KemperEffectSlot.EFFECT_SLOT_ID_D"
+ },
+ {
+ "name": "Slot X",
+ "value": "KemperEffectSlot.EFFECT_SLOT_ID_X"
+ },
+ {
+ "name": "Slot MOD",
+ "value": "KemperEffectSlot.EFFECT_SLOT_ID_MOD"
+ },
+ {
+ "name": "Slot DLY (with spillover)",
+ "value": "KemperEffectSlot.EFFECT_SLOT_ID_DLY"
+ },
+ {
+ "name": "Slot REV (with spillover)",
+ "value": "KemperEffectSlot.EFFECT_SLOT_ID_REV"
+ },
+ {
+ "name": "Slot DLY (no spillover)",
+ "value": "KemperEffectSlot.EFFECT_SLOT_ID_DLY_NO_SPILL"
+ },
+ {
+ "name": "Slot REV (no spillover)",
+ "value": "KemperEffectSlot.EFFECT_SLOT_ID_REV_NO_SPILL"
+ }
+ ]
+ },
+ {
+ "name": "rig_overrides",
+ "type": "rig_map",
+ "comment": "Per-rig slot override: for each listed rig, this button will control the chosen slot instead of the default one."
+ }
+ ],
+ "comment": "Switch an effect slot on / off, with per-rig slot override. Assign a different effect slot for specific rigs so the same button always controls the right effect regardless of how rigs are organized on the Kemper.",
+ "importPath": "pyswitch.clients.kemper.actions.effect_state_per_rig"
+ },
{
"entityName": "EFFECT_BUTTON",
"category": "effects",
diff --git a/web/htdocs/js/Controller.js b/web/htdocs/js/Controller.js
index 01982ac4..9574afc3 100644
--- a/web/htdocs/js/Controller.js
+++ b/web/htdocs/js/Controller.js
@@ -3,8 +3,8 @@
*/
class Controller {
- static PYSWITCH_VERSION = "2.4.8"; // Version of PySwitch this emulator is designed to run with
- static VERSION = Controller.PYSWITCH_VERSION + ".15"; // PySwitch Emulator Version
+ static PYSWITCH_VERSION = "2.4.9"; // Version of PySwitch this emulator is designed to run with
+ static VERSION = Controller.PYSWITCH_VERSION + ".16"; // PySwitch Emulator Version
ui = null; // User Interface implementation
routing = null; // sammy.js router
@@ -89,7 +89,7 @@ class Controller {
this.handle(e);
}
- // Initialize UI (settings panel etc.)
+ // Initialize UI (settings panel etc.)
await this.ui.build();
// Routing handler: Runs routing. see Routing.js for the callbacks which in turn call this controller again.
diff --git a/web/htdocs/js/PySwitchRunner.js b/web/htdocs/js/PySwitchRunner.js
index 22ae907c..8837f95f 100644
--- a/web/htdocs/js/PySwitchRunner.js
+++ b/web/htdocs/js/PySwitchRunner.js
@@ -126,6 +126,7 @@ class PySwitchRunner {
this.#loadModule("parser/misc/ImportExtractor.py", localPythonPath),
this.#loadModule("parser/misc/ReplaceAssignmentTransformer.py", localPythonPath),
this.#loadModule("parser/misc/AddAssignmentTransformer.py", localPythonPath),
+ this.#loadModule("parser/misc/RemoveAssignmentTransformer.py", localPythonPath),
this.#loadModule("parser/misc/CodeExtractor.py", localPythonPath),
this.#loadModule("parser/misc/CodeGenerator.py", localPythonPath),
@@ -191,6 +192,7 @@ class PySwitchRunner {
this.#loadModule("pyswitch/clients/kemper/actions/amp.py", circuitpyPath),
this.#loadModule("pyswitch/clients/kemper/actions/bank_select_encoder.py", circuitpyPath),
this.#loadModule("pyswitch/clients/kemper/actions/fixed_fx.py", circuitpyPath),
+ this.#loadModule("pyswitch/clients/kemper/actions/effect_state_per_rig.py", circuitpyPath),
this.#loadModule("pyswitch/clients/kemper/mappings/__init__.py", circuitpyPath),
this.#loadModule("pyswitch/clients/kemper/mappings/amp.py", circuitpyPath),
diff --git a/web/htdocs/js/model/parser/ParameterMeta.js b/web/htdocs/js/model/parser/ParameterMeta.js
index 152777da..505f2a4a 100644
--- a/web/htdocs/js/model/parser/ParameterMeta.js
+++ b/web/htdocs/js/model/parser/ParameterMeta.js
@@ -245,6 +245,7 @@ class ParameterMeta {
case "select-page": return getSelectDefault();
case "bool": return "False";
case "color": return "(0, 0, 0)";
+ case "rig_map": return "{}";
default: throw new Error("Unknown parameter type: " + this.data.type);
}
}
diff --git a/web/htdocs/js/model/parser/ParserInput.js b/web/htdocs/js/model/parser/ParserInput.js
index 2d1c46ed..f5e9201e 100644
--- a/web/htdocs/js/model/parser/ParserInput.js
+++ b/web/htdocs/js/model/parser/ParserInput.js
@@ -78,6 +78,50 @@ class ParserInput extends ParserTreeElement {
this.parser.updateConfig();
}
+ /**
+ * Returns the LED color of the switch (string like "Colors.RED" or "(255,0,0)"), or null if not set.
+ */
+ color() {
+ this.checkValid()
+ const arg = this.getArgument("color");
+ return (arg && arg.value) ? arg.value : null;
+ }
+
+ /**
+ * Sets the LED color of the switch. Pass null to remove (use firmware default).
+ */
+ setColor(color) {
+ this.checkValid()
+ if (color) {
+ this.setArgument("color", color);
+ } else {
+ this.removeArgument("color");
+ }
+ this.parser.updateConfig();
+ }
+
+ /**
+ * Returns the LED brightness of the switch [0..1], or null if not set.
+ */
+ brightness() {
+ this.checkValid()
+ const arg = this.getArgument("brightness");
+ return (arg && arg.value != null && arg.value !== "") ? parseFloat(arg.value) : null;
+ }
+
+ /**
+ * Sets the LED brightness of the switch [0..1]. Pass null to remove (use firmware default).
+ */
+ setBrightness(brightness) {
+ this.checkValid()
+ if (brightness !== null && brightness !== undefined && brightness !== "") {
+ this.setArgument("brightness", "" + parseFloat(brightness));
+ } else {
+ this.removeArgument("brightness");
+ }
+ this.parser.updateConfig();
+ }
+
/**
* Returns the actions of the input as arrays of ParserInputActions
*/
diff --git a/web/htdocs/js/model/parser/ParserTreeElement.js b/web/htdocs/js/model/parser/ParserTreeElement.js
index fd64dfd9..57207a7b 100644
--- a/web/htdocs/js/model/parser/ParserTreeElement.js
+++ b/web/htdocs/js/model/parser/ParserTreeElement.js
@@ -60,4 +60,12 @@ class ParserTreeElement {
}
)
}
+
+ /**
+ * Removes a specific argument by name. Does nothing if not found.
+ */
+ removeArgument(name) {
+ this.checkValid()
+ this.data.arguments = this.data.arguments.filter((arg) => arg.name != name);
+ }
}
\ No newline at end of file
diff --git a/web/htdocs/js/ui/misc/ParameterList.js b/web/htdocs/js/ui/misc/ParameterList.js
index 8b76dd09..7e8ddc68 100644
--- a/web/htdocs/js/ui/misc/ParameterList.js
+++ b/web/htdocs/js/ui/misc/ParameterList.js
@@ -125,19 +125,30 @@ class ParameterList {
async function onChange() {
try {
+ log.clear();
+
+ const value = options.getValue ? options.getValue(input) : input.val();
+
+ if (options.validate) {
+ const error = options.validate(value);
+ if (error) {
+ log.error(error);
+ return;
+ }
+ }
+
if (options.onChange) {
- const value = options.getValue ? options.getValue(input) : input.val();
await options.onChange(value, function(newValue) {
if (value == newValue) return;
-
+
if (options.setValue) {
options.setValue(input, newValue);
return;
}
input.val(newValue);
- });
+ });
}
-
+
} catch (e) {
that.controller.handle(e);
}
diff --git a/web/htdocs/js/ui/parser/ActionProperties.js b/web/htdocs/js/ui/parser/ActionProperties.js
index 28e080b9..961fc2e4 100644
--- a/web/htdocs/js/ui/parser/ActionProperties.js
+++ b/web/htdocs/js/ui/parser/ActionProperties.js
@@ -482,7 +482,13 @@ class ActionProperties {
// Dedicated type for the pager actions's "pages" parameter
return this.#pagers.getPagesList(onChange);
}
- }
+
+ case 'rig_map': {
+ // Dedicated type for EFFECT_STATE_PER_RIG's rig_overrides parameter.
+ // Renders a table where each row maps a Bank/Rig pair to an effect slot.
+ return ActionProperties.#createRigMapInput(onChange);
+ }
+ }
return $('')
.on('change', onChange)
@@ -599,7 +605,8 @@ class ActionProperties {
switch(type) {
case "bool": return input.prop('checked') ? "True" : "False";
case "pages": return this.#pagers.pages.get();
- }
+ case "rig_map": return ActionProperties.#getRigMapValue(input);
+ }
let value = input.val();
if (value == "") value = param.meta.getDefaultValue();
@@ -623,10 +630,232 @@ class ActionProperties {
await this.#pagers.pages.set(value)
break;
+ case "rig_map":
+ ActionProperties.#setRigMapValue(input, value);
+ break;
+
default:
input.val(value.replaceAll('"', "'"));
input.trigger('change');
- }
+ }
+ }
+
+ // -------------------------------------------------------------------------
+ // rig_map type: per-rig slot override table for EFFECT_STATE_PER_RIG
+ // -------------------------------------------------------------------------
+
+ static #RIG_MAP_SLOTS = [
+ { name: "None (disabled)", value: "None" },
+ { name: "Slot A", value: "KemperEffectSlot.EFFECT_SLOT_ID_A" },
+ { name: "Slot B", value: "KemperEffectSlot.EFFECT_SLOT_ID_B" },
+ { name: "Slot C", value: "KemperEffectSlot.EFFECT_SLOT_ID_C" },
+ { name: "Slot D", value: "KemperEffectSlot.EFFECT_SLOT_ID_D" },
+ { name: "Slot X", value: "KemperEffectSlot.EFFECT_SLOT_ID_X" },
+ { name: "Slot MOD", value: "KemperEffectSlot.EFFECT_SLOT_ID_MOD" },
+ { name: "Slot DLY (spillover)",value: "KemperEffectSlot.EFFECT_SLOT_ID_DLY" },
+ { name: "Slot REV (spillover)",value: "KemperEffectSlot.EFFECT_SLOT_ID_REV" },
+ { name: "Slot DLY (no spill)", value: "KemperEffectSlot.EFFECT_SLOT_ID_DLY_NO_SPILL" },
+ { name: "Slot REV (no spill)", value: "KemperEffectSlot.EFFECT_SLOT_ID_REV_NO_SPILL" },
+ ];
+
+ /**
+ * Creates the rig_map table container element.
+ * onChange is called whenever the user modifies a row.
+ */
+ static #createRigMapInput(onChange) {
+ const container = $('');
+ container.data('onChange', onChange);
+
+ const table = $('
').append(
+ $('').append(
+ $('
').append(
+ $('
').text('Bank'),
+ $('
').text('Rig'),
+ $('
').text('Slot(s)')
+ )
+ )
+ );
+ const tbody = $('');
+ table.append(tbody);
+ container.append(table);
+
+ const addBtn = $('').text('+ Add override');
+ addBtn.on('click', function() {
+ ActionProperties.#addRigMapRow(tbody, 1, 1, [ActionProperties.#RIG_MAP_SLOTS[1].value], onChange);
+ onChange();
+ });
+ container.append(addBtn);
+
+ return container;
+ }
+
+ /**
+ * Appends one row to the rig_map tbody.
+ * slotValues: array of slot value strings, or a single string (e.g. 'None').
+ * Multiple slots produce AND logic in the firmware: LED is ON only when ALL are ON.
+ */
+ static #addRigMapRow(tbody, bank, rig, slotValues, onChange) {
+ if (!Array.isArray(slotValues)) slotValues = [slotValues];
+ if (slotValues.length === 0) slotValues = [ActionProperties.#RIG_MAP_SLOTS[1].value];
+
+ const rigSelect = $('').append(
+ [1,2,3,4,5].map(n => $('').val(n).text(n))
+ ).val(rig).on('change', onChange);
+
+ const bankInput = $('')
+ .val(bank).on('change', onChange);
+
+ const slotsContainer = $('');
+
+ // "+" button to add a second/third slot to this row
+ const addSlotBtn = $('').text('+');
+
+ function updateAddBtnState() {
+ const firstSelect = slotsContainer.find('.rig-slot').first();
+ addSlotBtn.prop('disabled', firstSelect.length > 0 && firstSelect.val() === 'None');
+ }
+
+ function appendSlotRow(slotValue, isFirst) {
+ // First slot includes "None (disabled)"; subsequent slots exclude it
+ const options = isFirst
+ ? ActionProperties.#RIG_MAP_SLOTS
+ : ActionProperties.#RIG_MAP_SLOTS.filter(s => s.value !== 'None');
+
+ const select = $('').append(
+ options.map(s => $('').val(s.value).text(s.name))
+ ).val(slotValue).on('change', function() {
+ if (isFirst && $(this).val() === 'None') {
+ // Disabled: remove extra slot rows
+ slotsContainer.find('.rig-slot-row').not(':first').remove();
+ }
+ updateAddBtnState();
+ onChange();
+ });
+
+ const slotRow = $('').append(select);
+
+ if (!isFirst) {
+ const removeSlotBtn = $('').text('-');
+ removeSlotBtn.on('click', function() {
+ slotRow.remove();
+ updateAddBtnState();
+ onChange();
+ });
+ slotRow.append(removeSlotBtn);
+ }
+
+ // Insert before addSlotBtn if it is already in the container,
+ // so + and ✕ always stay at the bottom.
+ if (addSlotBtn.parent().length) {
+ addSlotBtn.before(slotRow);
+ } else {
+ slotsContainer.append(slotRow);
+ }
+ }
+
+ const isNoneValue = slotValues.length === 1 && slotValues[0] === 'None';
+ for (let i = 0; i < slotValues.length; i++) {
+ appendSlotRow(slotValues[i], i === 0);
+ }
+
+ addSlotBtn.prop('disabled', isNoneValue);
+ addSlotBtn.on('click', function() {
+ const firstNonNone = ActionProperties.#RIG_MAP_SLOTS.find(s => s.value !== 'None');
+ appendSlotRow(firstNonNone ? firstNonNone.value : ActionProperties.#RIG_MAP_SLOTS[1].value, false);
+ updateAddBtnState();
+ onChange();
+ });
+ const removeBtn = $('').text('✕');
+ slotsContainer.append(addSlotBtn, removeBtn);
+
+ const row = $('
').append(
+ $('
').append(bankInput),
+ $('
').append(rigSelect),
+ $('
').append(slotsContainer)
+ );
+ removeBtn.on('click', function() {
+ row.remove();
+ onChange();
+ });
+ tbody.append(row);
+ }
+
+ /**
+ * Reads the rig_map table and returns a Python dict string.
+ * Single slot → absRig: KemperEffectSlot.EFFECT_SLOT_ID_C
+ * Disabled → absRig: None
+ * Multi-slot → absRig: [KemperEffectSlot.EFFECT_SLOT_ID_C, KemperEffectSlot.EFFECT_SLOT_ID_MOD]
+ * Absolute rig ID = (bank - 1) * 5 + (rig - 1)
+ */
+ static #getRigMapValue(container) {
+ const entries = [];
+ container.find('tbody tr').each(function() {
+ const bank = parseInt($(this).find('.rig-bank').val()) || 1;
+ const rig = parseInt($(this).find('.rig-rig').val()) || 1;
+ const absRig = (bank - 1) * 5 + (rig - 1);
+ const slotSelects = $(this).find('.rig-slot');
+ if (slotSelects.length === 1) {
+ entries.push(absRig + ': ' + (slotSelects.val() || ActionProperties.#RIG_MAP_SLOTS[0].value));
+ } else {
+ const slots = slotSelects.map(function() {
+ return $(this).val() || ActionProperties.#RIG_MAP_SLOTS[1].value;
+ }).get();
+ entries.push(absRig + ': [' + slots.join(', ') + ']');
+ }
+ });
+ return '{' + entries.join(', ') + '}';
+ }
+
+ /**
+ * Parses a Python dict string and populates the rig_map table.
+ * Supports single slots, None, and list values:
+ * {0: [KemperEffectSlot.EFFECT_SLOT_ID_C, KemperEffectSlot.EFFECT_SLOT_ID_MOD], 4: None}
+ */
+ static #setRigMapValue(container, value, onChange) {
+ if (onChange === undefined) onChange = container.data('onChange') || function() {};
+ const tbody = container.find('tbody');
+ tbody.empty();
+
+ if (!value || value.trim() === '{}') return;
+
+ const inner = value.trim().replace(/^\{/, '').replace(/\}$/, '').trim();
+ if (!inner) return;
+
+ // Split at top-level commas (not inside [...])
+ const pairs = [];
+ let depth = 0, current = '';
+ for (const ch of inner) {
+ if (ch === '[') depth++;
+ else if (ch === ']') depth--;
+ if (ch === ',' && depth === 0) {
+ pairs.push(current.trim());
+ current = '';
+ } else {
+ current += ch;
+ }
+ }
+ if (current.trim()) pairs.push(current.trim());
+
+ for (const pair of pairs) {
+ const colonIdx = pair.indexOf(':');
+ if (colonIdx < 0) continue;
+ const absRigStr = pair.substring(0, colonIdx).trim();
+ const slotStr = pair.substring(colonIdx + 1).trim();
+ const absRig = parseInt(absRigStr.replace(/['"]/g, '').trim());
+ if (isNaN(absRig)) continue;
+ const bank = Math.floor(absRig / 5) + 1;
+ const rig = (absRig % 5) + 1;
+
+ let slotValues;
+ if (slotStr.startsWith('[') && slotStr.endsWith(']')) {
+ const listInner = slotStr.slice(1, -1).trim();
+ slotValues = listInner.split(',').map(s => s.trim()).filter(s => s.length > 0);
+ } else {
+ slotValues = [slotStr];
+ }
+
+ ActionProperties.#addRigMapRow(tbody, bank, rig, slotValues, onChange);
+ }
}
/**
diff --git a/web/htdocs/js/ui/parser/InputSettings.js b/web/htdocs/js/ui/parser/InputSettings.js
index 7e7ba0b3..d609ea49 100644
--- a/web/htdocs/js/ui/parser/InputSettings.js
+++ b/web/htdocs/js/ui/parser/InputSettings.js
@@ -6,8 +6,8 @@ class InputSettings extends ParameterList {
#definition = null;
#input = null;
- constructor(controller, definition, input) {
- super(controller)
+ constructor(controller, definition, input, parser = null) {
+ super(controller, parser)
this.#definition = definition;
this.#input = input;
}
@@ -58,9 +58,52 @@ class InputSettings extends ParameterList {
});
}
+ async function getLedOptions() {
+ await that.createColorInput({
+ name: "color",
+ displayName: "LED Color",
+ comment: "Color of the switch LEDs when no action controls them. Leave empty to use the firmware default (white).",
+ value: that.#input ? (that.#input.color() || "") : "",
+ onChange: async function(value) {
+ that.#input.setColor(value || null);
+
+ await that.controller.restart({
+ message: "none"
+ });
+ }
+ });
+
+ await that.createNumericInput({
+ name: "brightness",
+ displayName: "LED Brightness",
+ comment: "Brightness of the switch LEDs when no action controls them. Range: [0..1]. Leave empty to use the firmware default (off).",
+ value: that.#input ? (that.#input.brightness() !== null ? that.#input.brightness() : "") : "",
+ range: {
+ min: 0,
+ max: 1,
+ step: 0.01
+ },
+ validate: function(value) {
+ if (value === "") return null;
+ const n = parseFloat(value);
+ if (isNaN(n) || n < 0 || n > 1) return "Must be between 0 and 1";
+ return null;
+ },
+ onChange: async function(value) {
+ const parsed = value !== "" ? parseFloat(value) : null;
+ that.#input.setBrightness(parsed);
+
+ await that.controller.restart({
+ message: "none"
+ });
+ }
+ });
+ }
+
switch (this.#definition.data.model.type) {
- case "AdafruitSwitch":
+ case "AdafruitSwitch":
await getSwitchOptions();
+ await getLedOptions();
break;
}
}
diff --git a/web/htdocs/js/ui/parser/ParserFrontendInput.js b/web/htdocs/js/ui/parser/ParserFrontendInput.js
index 38b80b54..ffe72cf8 100644
--- a/web/htdocs/js/ui/parser/ParserFrontendInput.js
+++ b/web/htdocs/js/ui/parser/ParserFrontendInput.js
@@ -187,7 +187,7 @@ class ParserFrontendInput {
}),
// Input settings button
- !(await (new InputSettings(this.#controller, this.definition, this.input).get())) ? null :
+ !(await (new InputSettings(this.#controller, this.definition, this.input, this.#parserFrontend.parser).get())) ? null :
$('')
.on('click', async function() {
try {
@@ -518,6 +518,7 @@ class ParserFrontendInput {
const browser = this.#controller.ui.getPopup({
onReturnKey: commit,
+ additionalClasses: "medium",
buttons: [
{
text: "Done",
@@ -529,7 +530,8 @@ class ParserFrontendInput {
const props = new InputSettings(
this.#controller,
this.definition,
- this.input
+ this.input,
+ this.#parserFrontend.parser
);
const propsContent = await props.get();
diff --git a/web/htdocs/python/parser/PySwitchParser.py b/web/htdocs/python/parser/PySwitchParser.py
index 8e3e8bdb..227d8760 100644
--- a/web/htdocs/python/parser/PySwitchParser.py
+++ b/web/htdocs/python/parser/PySwitchParser.py
@@ -12,6 +12,7 @@
from .misc.ImportExtractor import ImportExtractor
from .misc.ReplaceAssignmentTransformer import ReplaceAssignmentTransformer
from .misc.AddAssignmentTransformer import AddAssignmentTransformer
+from .misc.RemoveAssignmentTransformer import RemoveAssignmentTransformer
from .misc.ClassNameExtractor import ClassNameExtractor
class PySwitchParser:
@@ -116,18 +117,32 @@ def set_splashes(self, splashes):
# Remove first assign as this can lead to endless recursion and is not relevant anyway
splashes_py = splashes.to_py()
- if "assign" in splashes_py:
+ if "assign" in splashes_py:
splashes_py["assign"] = None
+ # Collect all assign names referenced in the new tree BEFORE code generation,
+ # so we can remove stale DisplayLabel constants that are no longer referenced.
+ referenced_assigns = self._collect_assign_names(splashes_py)
+
splashes_node = CodeGenerator(
- parser = self,
- file_id = "display_py",
+ parser = self,
+ file_id = "display_py",
insert_before_assign = "Splashes",
format = True
).generate(splashes_py)
-
+
self.set_assignment("Splashes", splashes_node, "display_py")
+ # Remove orphaned DisplayLabel assignments that are no longer used in Splashes
+ # and not referenced in inputs.py (the JS frontend already prevents removal
+ # of referenced labels, but we double-check here for safety).
+ current_names = AssignmentNameExtractor().get(self.__csts["display_py"])
+ for name in current_names:
+ if name.startswith("_") or name == "Splashes":
+ continue
+ if name not in referenced_assigns:
+ self.remove_assignment(name, "display_py")
+
########################################################################################
# Adds or replaces the given assignment by name.
@@ -138,14 +153,33 @@ def set_assignment(self, name, call_node, file_id, insert_before_assign = None):
return
adder = AddAssignmentTransformer(
- name,
- call_node,
- insert_before_assign = insert_before_assign,
+ name,
+ call_node,
+ insert_before_assign = insert_before_assign,
cst = self.__csts[file_id] if not insert_before_assign else None
)
self.__csts[file_id] = self.__csts[file_id].visit(adder)
+ # Remove the given assignment by name.
+ def remove_assignment(self, name, file_id):
+ remover = RemoveAssignmentTransformer(name)
+ self.__csts[file_id] = self.__csts[file_id].visit(remover)
+
+ # Recursively collect all "assign" names from a parsed tree dict.
+ def _collect_assign_names(self, data, names = None):
+ if names is None:
+ names = set()
+ if isinstance(data, dict):
+ if "assign" in data and data["assign"]:
+ names.add(data["assign"])
+ for value in data.values():
+ self._collect_assign_names(value, names)
+ elif isinstance(data, list):
+ for item in data:
+ self._collect_assign_names(item, names)
+ return names
+
########################################################################################
# Remove unused imports on all files. Does no config update!
diff --git a/web/htdocs/python/parser/misc/RemoveAssignmentTransformer.py b/web/htdocs/python/parser/misc/RemoveAssignmentTransformer.py
new file mode 100644
index 00000000..ea697380
--- /dev/null
+++ b/web/htdocs/python/parser/misc/RemoveAssignmentTransformer.py
@@ -0,0 +1,24 @@
+import libcst
+from .VisitorsWithStack import TransformerWithStack
+
+
+class RemoveAssignmentTransformer(TransformerWithStack):
+ """Remove a top-level assignment by name."""
+
+ def __init__(self, name):
+ super().__init__()
+ self._name = name
+ self.removed = False
+
+ def leave_SimpleStatementLine(self, original_node, updated_node):
+ # Module body level (stack = [Module, SimpleStatementLine])
+ if len(self.stack) != 1:
+ return updated_node
+
+ if len(updated_node.body) == 1 and isinstance(updated_node.body[0], libcst.Assign):
+ for target in updated_node.body[0].targets:
+ if isinstance(target.target, libcst.Name) and target.target.value == self._name:
+ self.removed = True
+ return libcst.RemovalSentinel.REMOVE
+
+ return updated_node
diff --git a/web/htdocs/python/wrappers/wrap_adafruit_led.py b/web/htdocs/python/wrappers/wrap_adafruit_led.py
index 1d147a20..d7f4f489 100644
--- a/web/htdocs/python/wrappers/wrap_adafruit_led.py
+++ b/web/htdocs/python/wrappers/wrap_adafruit_led.py
@@ -23,13 +23,15 @@ def __setitem__(self, key, value):
# Store original value for testing
led.dataset.color = [v for v in value]
- # When black, make transparent
+ # When black, show as off (dark)
if value == (0, 0, 0):
- led.style.backgroundColor = f"rgba(0, 0, 0, 0)"
+ led.style.backgroundColor = f"rgb(0, 0, 0)"
return
# Apply gamma correction
def trans(x, gamma = 0.28):
+ if x <= 0:
+ return 0
return pow((x/255), gamma) * 255
def clip(x):
diff --git a/web/htdocs/styles/list-browser.css b/web/htdocs/styles/list-browser.css
index 56c931e4..48ab72bc 100644
--- a/web/htdocs/styles/list-browser.css
+++ b/web/htdocs/styles/list-browser.css
@@ -32,6 +32,10 @@
border-radius: 1.5em;
}
+.container .list-browser.medium {
+ max-width: 750px;
+}
+
.container .list-browser.wide {
max-width: 1400px;
max-height: 1200px;
diff --git a/web/htdocs/styles/parser-action-properties.css b/web/htdocs/styles/parser-action-properties.css
index e729e7cc..314260b2 100644
--- a/web/htdocs/styles/parser-action-properties.css
+++ b/web/htdocs/styles/parser-action-properties.css
@@ -133,3 +133,86 @@
.container .action-properties select.parameter-option {
max-width: 8em;
}
+
+/**********************************************************************************/
+/* rig_map: per-rig slot override table */
+
+.container .action-properties .rig-map-container {
+ display: flex;
+ flex-direction: column;
+ gap: 0.5em;
+}
+
+.container .action-properties .rig-map-table {
+ border-collapse: collapse;
+ width: 100%;
+}
+
+.container .action-properties .rig-map-table th,
+.container .action-properties .rig-map-table td {
+ border: none;
+ padding: 0.25em 0.4em;
+ vertical-align: middle;
+}
+
+.container .action-properties .rig-map-table th {
+ font-weight: bold;
+ font-size: 0.85em;
+ color: gray;
+ text-align: left;
+}
+
+.container .action-properties .rig-slots-td {
+ min-width: 12em;
+}
+
+.container .action-properties .rig-slots-container {
+ display: flex;
+ flex-direction: column;
+ gap: 0.2em;
+ align-items: flex-start;
+ position: relative;
+}
+
+.container .action-properties .rig-slot-row {
+ display: flex;
+ align-items: center;
+ gap: 0.3em;
+}
+
+.container .action-properties .rig-slot-add,
+.container .action-properties .rig-slot-remove {
+ font-size: 0.85em;
+ padding: 0.1em 0.4em;
+ cursor: pointer;
+ border-radius: 0.3em;
+}
+
+.container .action-properties .rig-slot-add:disabled {
+ opacity: 0.4;
+ cursor: default;
+}
+
+.container .action-properties .rig-map-add {
+ align-self: flex-start;
+ font-size: 0.85em;
+}
+
+.container .action-properties .rig-remove {
+ display: inline-block;
+ font-size: 0.8em;
+ padding: 0.1em 0.45em;
+ cursor: pointer;
+ border-radius: 0.3em;
+ color: #555;
+ border: 1px solid #999;
+ background: transparent;
+ line-height: 1.4;
+ margin-top: 0.15em;
+ align-self: flex-start;
+}
+
+.container .action-properties .rig-remove:hover {
+ background: #ddd;
+ color: #222;
+}
diff --git a/web/serve.py b/web/serve.py
new file mode 100644
index 00000000..f47cfe6d
--- /dev/null
+++ b/web/serve.py
@@ -0,0 +1,179 @@
+#!/usr/bin/env python3
+"""
+Minimal HTTP server for PySwitch web editor.
+Replaces Apache + PHP: handles toc.php requests and serves static files.
+
+Usage:
+ python serve.py [port] default port: 8080
+
+Open Chrome at http://localhost:8080
+(Chrome required for Web MIDI API)
+"""
+import http.server
+import json
+import mimetypes
+import sys
+import threading
+import time
+import urllib.parse
+from pathlib import Path
+
+# Explicit MIME type table — overrides the Windows registry which often maps
+# .js to text/html, causing Chrome to refuse script execution.
+_MIME_TYPES = {
+ ".html": "text/html; charset=utf-8",
+ ".htm": "text/html; charset=utf-8",
+ ".css": "text/css",
+ ".js": "application/javascript",
+ ".mjs": "application/javascript",
+ ".json": "application/json",
+ ".wasm": "application/wasm",
+ ".png": "image/png",
+ ".jpg": "image/jpeg",
+ ".jpeg": "image/jpeg",
+ ".gif": "image/gif",
+ ".svg": "image/svg+xml",
+ ".ico": "image/x-icon",
+ ".woff": "font/woff",
+ ".woff2":"font/woff2",
+ ".ttf": "font/ttf",
+ ".otf": "font/otf",
+ ".eot": "application/vnd.ms-fontobject",
+ ".txt": "text/plain",
+ ".py": "text/plain",
+ ".md": "text/plain",
+ ".map": "application/json",
+}
+
+BASE_DIR = Path(__file__).parent.resolve()
+HTDOCS = BASE_DIR / "htdocs"
+CONTENT = BASE_DIR.parent / "content"
+EXAMPLES = BASE_DIR.parent / "examples"
+
+# Order matters: longest prefix first
+MOUNTS = [
+ ("/circuitpy", CONTENT),
+ ("/examples", EXAMPLES),
+]
+
+def resolve_path(url_path: str) -> Path:
+ """Map a URL path to a filesystem path, honouring volume mounts."""
+ for prefix, fs_root in MOUNTS:
+ if url_path == prefix or url_path.startswith(prefix + "/"):
+ rel = url_path[len(prefix):].lstrip("/")
+ return fs_root / rel
+ return HTDOCS / url_path.lstrip("/")
+
+
+def make_toc(directory: Path) -> dict:
+ """
+ Replicate the JSON output of toc.php.
+ {"type":"dir","name":"","path":"","children":[...]}
+ """
+ def fill(d: Path) -> list:
+ nodes = []
+ try:
+ entries = sorted(d.iterdir(), key=lambda e: (e.is_file(), e.name))
+ except PermissionError:
+ return nodes
+ for entry in entries:
+ if entry.name.startswith("."):
+ continue
+ if entry.is_dir():
+ nodes.append({"type": "dir", "name": entry.name,
+ "children": fill(entry)})
+ elif entry.is_file():
+ nodes.append({"type": "file", "name": entry.name})
+ return nodes
+
+ return {"type": "dir", "name": "", "path": "",
+ "children": fill(directory)}
+
+
+class PySwitchHandler(http.server.BaseHTTPRequestHandler):
+
+ def do_HEAD(self):
+ # Reuse GET logic but discard the body
+ self._head_only = True
+ self.do_GET()
+ self._head_only = False
+
+ def do_GET(self):
+ if not hasattr(self, '_head_only'):
+ self._head_only = False
+ parsed = urllib.parse.urlparse(self.path)
+ url_path = urllib.parse.unquote(parsed.path)
+ fs_path = resolve_path(url_path)
+
+ # ---- toc.php -------------------------------------------------------
+ if fs_path.name == "toc.php":
+ directory = fs_path.parent
+ if not directory.is_dir():
+ self._send_error(404, f"Directory not found: {directory}")
+ return
+ body = json.dumps(make_toc(directory), separators=(",", ":")).encode()
+ self._send_bytes(body, "application/json")
+ return
+
+ # ---- directory → index.html ----------------------------------------
+ if fs_path.is_dir():
+ fs_path = fs_path / "index.html"
+
+ # ---- static file ---------------------------------------------------
+ if not fs_path.exists() or not fs_path.is_file():
+ self._send_error(404, f"Not found: {url_path}")
+ return
+
+ suffix = fs_path.suffix.lower()
+ content_type = _MIME_TYPES.get(suffix) or mimetypes.guess_type(str(fs_path))[0] or "application/octet-stream"
+ try:
+ data = fs_path.read_bytes()
+ except OSError as exc:
+ self._send_error(500, str(exc))
+ return
+ self._send_bytes(data, content_type)
+
+ # ------------------------------------------------------------------
+ def _send_bytes(self, data: bytes, content_type: str):
+ self.send_response(200)
+ self.send_header("Content-Type", content_type)
+ self.send_header("Content-Length", str(len(data)))
+ self.end_headers()
+ if not self._head_only:
+ self.wfile.write(data)
+
+ def _send_error(self, code: int, msg: str = ""):
+ body = msg.encode()
+ self.send_response(code)
+ self.send_header("Content-Type", "text/plain")
+ self.send_header("Content-Length", str(len(body)))
+ self.end_headers()
+ self.wfile.write(body)
+
+ def handle_error(self, request, client_address):
+ # Silently ignore abrupt client disconnections (e.g. browser reload/cancel).
+ exc = sys.exc_info()[1]
+ if isinstance(exc, (ConnectionResetError, BrokenPipeError)):
+ return
+ super().handle_error(request, client_address)
+
+ def log_message(self, fmt, *args):
+ # Log everything so we can spot 404s and errors in the terminal
+ super().log_message(fmt, *args)
+
+
+if __name__ == "__main__":
+ port = int(sys.argv[1]) if len(sys.argv) > 1 else 9090
+ server = http.server.HTTPServer(("0.0.0.0", port), PySwitchHandler)
+ url = f"http://localhost:{port}"
+ print(f"PySwitch editor running at {url}")
+ print("Open this URL in Chrome (required for Web MIDI)")
+ print("Press Ctrl+C to stop\n")
+ t = threading.Thread(target=server.serve_forever, daemon=True)
+ t.start()
+ try:
+ while t.is_alive():
+ time.sleep(0.5)
+ except KeyboardInterrupt:
+ server.shutdown()
+ print("\nStopped.")