Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
44 changes: 36 additions & 8 deletions docs/protocol.md
Original file line number Diff line number Diff line change
Expand Up @@ -1396,18 +1396,44 @@ sessions hold one `MOVE` (session 02, frame 25825) and one `DELETE` (session

### 10.3 Setlists

**Creating a setlist** is captured from the unit's own "New Setlist" and
confirmed host to unit. Setlists sit side by side under `/media/p4/Presets`:
Setlists sit side by side under `/media/p4/Presets`. The create shape is
profile-specific:

```
File{CREATE, type: 0, folder{key: "/media/p4/Presets/<name>", name: "<name>",
is_factory: false}}
CorOS 4.0.1:
File{CREATE, type: 0, folder{key: "/media/p4/Presets/<name>", name: "<name>"}}

CorOS 4.1.0:
File{CREATE, type: 0, folder{key: "/media/p4/Presets/<name>",
parent_key: "/media/p4/Presets",
name: "<name>", is_factory: false}}
```

The 4.0.1 create was captured from the unit's own "New Setlist" action and
confirmed host to unit. The 4.1.0 create shape was sent by the library on a
CorOS 4.1.0 unit on 2026-09-27; a fresh folder listing confirmed the disposable
setlist appeared. On both profiles the new key works anywhere a setlist path
does. So the MIDI documentation's "User folders" at bank-select LSB 2 to 12
are folders a player creates.

Delete is also profile-specific. CorOS 4.0.1 uses the shape captured on that
firmware:

```
File{DELETE, folder{key, name}}
```

CorOS 4.1.0's Cortex Control shape omits `name` and sends explicit false flags:

```
File{DELETE, type: 0, folder{key, is_factory: false},
delete_from_library: false}
```

The new key appears in the folder listing and works anywhere a setlist path
does. So the MIDI documentation's "User folders" at bank-select LSB 2 to 12 are
folders a player creates. **Deleting a setlist** is `File{DELETE, folder{key,
name}}` against the setlist's own key.
Both delete shapes were sent on CorOS 4.1.0 on 2026-09-27 against separate
empty disposable setlists; a fresh listing confirmed each folder was removed.
The 4.0.1 shape remains the base profile's behavior, while `QuadCortex41`
uses the 4.1.0 shape.

**There is no host-drivable copy.** The unit's duplicate action sends a `File`
`CREATE` for the destination and then narrates itself through `BulkOperation`
Expand Down Expand Up @@ -2161,7 +2187,9 @@ wire, with no independent read-back.
| `master_volume` / `set_master_volume` | `MasterVolume{READ}` / `{UPDATE, volume}` | read-back + on-unit + by ear | takes `Encoded`; the screen shows `round(v * 100)`. Never add `calibrate` |
| `pin_model` / `unpin_model` / `pinned_models` | `PinnedModels{models}` with no action / `{DELETE, models}` | read-back + on-unit | pinning appends and can duplicate; `DELETE` removes every entry for an id |
| `delete_setlist` | `File{DELETE, folder{key, name}}` | read-back | removes the setlist and its contents |
| `delete_setlist` on CorOS 4.1.0 | `File{DELETE, folder{key, is_factory: false}, delete_from_library: false}` | read-back | measured 2026-09-27 on an empty disposable setlist; the legacy shape also removed a separate disposable setlist on 4.1.0; the 4.0.1 base shape remains profile-local |
| `create_setlist` | `File{CREATE, folder{key: "/media/p4/Presets/<name>", name}}` | read-back + on-unit | setlists are siblings under the presets root |
| `create_setlist` on CorOS 4.1.0 | `File{CREATE, folder{key, parent_key: "/media/p4/Presets", name, is_factory: false}}` | read-back | measured 2026-09-27 on an empty disposable setlist; the 4.0.1 shape above remains profile-local |
| `copy_preset` / `duplicate_setlist` | recall then `File{CREATE}` per preset | read-back | compositions; each recalls the source on the unit |
| `set_split_mute` | `Grid{UPDATE, preset{chains{row, splitBypass{bypass}}}}` | read-back | reported back in `mixBypass`; one write sets all eight scenes |
| `set_stomp_assignment` / `clear_stomp_assignment` | `Grid{DELETE, stomp_mode_assignments{row, column}}` then `Grid{UPDATE, ...{stomp_index}}` | read-back + on-unit | the unit's own two-message sequence |
Expand Down
6 changes: 3 additions & 3 deletions pyquadcortex/protocol/client.py
Original file line number Diff line number Diff line change
Expand Up @@ -3663,9 +3663,9 @@ def delete_setlist(self, name: str):
"""Delete a setlist and whatever it holds.

``File{DELETE, folder{key, name}}`` against the setlist's own key.
Confirmed: the folder disappears from the listing. Like the other file
operations this is eventually consistent, so re-enumerate rather than
checking immediately.
Confirmed on CorOS 4.0.1: the folder disappears from the listing. Like
the other file operations this is eventually consistent, so
re-enumerate rather than checking immediately.
"""
msg = pa.FileMessage(action=pa.MessageAction.DELETE, type=0)
msg.folder.key = f"{USER_SETLIST_ROOT}/{name}"
Expand Down
34 changes: 32 additions & 2 deletions pyquadcortex/protocol/profiles.py
Original file line number Diff line number Diff line change
Expand Up @@ -8,7 +8,7 @@
"""
from __future__ import annotations

from pyquadcortex.protocol.client import QuadCortex
from pyquadcortex.protocol.client import QuadCortex, USER_SETLIST_ROOT
from pyquadcortex.protocol.proto import ProductionAutomation_pb2 as pa
from pyquadcortex.protocol.support import Evidence, Hardware, NoSnapshot

Expand Down Expand Up @@ -43,11 +43,41 @@ class QuadCortex41(QuadCortex):

MEASURED_ON = ("4.1.0",)
EVIDENCE = Evidence.CONTRIBUTED
VERIFIED = frozenset({"create_local_backup", "set_device_name"})
VERIFIED = frozenset({
"create_local_backup", "create_setlist", "delete_setlist",
"set_device_name"})
models = NoSnapshot("coros_4_1_0")
params = NoSnapshot("coros_4_1_0")
options = NoSnapshot("coros_4_1_0")

def create_setlist(self, name: str):
"""Create a user setlist using the CorOS 4.1 Cortex Control shape.

Measured on CorOS 4.1.0 on 2026-09-27 against a disposable setlist:
``parent_key`` is present and listing read-back confirms creation.
"""
msg = pa.FileMessage(type=0)
msg.folder.key = f"{USER_SETLIST_ROOT}/{name}"
msg.folder.parent_key = USER_SETLIST_ROOT
msg.folder.name = name
msg.folder.is_factory = False
self._file_operation(msg)
return msg.folder.key

def delete_setlist(self, name: str):
"""Delete a user setlist using the measured CorOS 4.1 shape.

Measured on CorOS 4.1.0 on 2026-09-27 against an empty disposable
setlist: omitting ``name`` and sending the explicit false flags removed
it, as confirmed by listing read-back. The legacy 4.0.1 shape also
removed a separate disposable setlist on this firmware.
"""
msg = pa.FileMessage(action=pa.MessageAction.DELETE, type=0)
msg.folder.key = f"{USER_SETLIST_ROOT}/{name}"
msg.folder.is_factory = False
msg.delete_from_library = False
return self._file_operation(msg)


class QuadCortexMini(QuadCortex):
"""Quad Cortex Mini - recognised, not supported, and here to be finished.
Expand Down
94 changes: 94 additions & 0 deletions tests/hardware/test_setlist_operations.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,94 @@
"""Disposable-folder hardware checks for the profile-owned setlist shapes."""

import time
import uuid

import pytest

FOLDER_LIST_TIMEOUT = 20.0


def _folder_named(qc, key, *, seconds=FOLDER_LIST_TIMEOUT):
return next((folder for folder in qc.list_folders(seconds=seconds)
if folder.key.rstrip("/") == key.rstrip("/")), None)


def _wait_for_folder(qc, key, *, present, timeout=90.0):
deadline = time.monotonic() + timeout
while True:
# A complete device listing can take about fifteen seconds; do not
# cut it off at ten and mistake a slow generation for a missing folder.
folder = _folder_named(qc, key)
if (folder is not None) is present:
return folder
remaining = deadline - time.monotonic()
if remaining <= 0:
state = "appear" if present else "disappear"
raise AssertionError(
f"disposable setlist {key!r} did not {state} within {timeout}s")
time.sleep(min(2.0, remaining))


@pytest.mark.verifies("create_setlist", "delete_setlist")
def test_create_and_delete_an_empty_disposable_setlist(qc, profile):
"""Exercise both exact profile builders without touching user content.

The only folder this test may delete is a unique, preflight-absent name
that it just created and read back as empty. It never saves, recalls, moves,
or deletes a preset. If DELETE has an uncertain result, it reads the folder
state and does not replay the persistent write.
"""
operations = ("create_setlist", "delete_setlist")
if ("4.1.0" not in profile.MEASURED_ON
or any(name not in profile.VERIFIED for name in operations)):
pytest.skip(f"setlist writes are not VERIFIED on {profile.__name__}")

from pyquadcortex.protocol.client import USER_SETLIST_ROOT

name = f"QC disposable {uuid.uuid4().hex[:12]}"
key = f"{USER_SETLIST_ROOT}/{name}"
assert _folder_named(qc, key) is None, (
f"random disposable name already exists; refusing to touch {key!r}")

create_attempted = False
delete_attempted = False
deleted = False
try:
# Set before invoking CREATE because a transport timeout can happen
# after the device has accepted the persistent write.
create_attempted = True
assert qc.create_setlist(name) == key
folder = _wait_for_folder(qc, key, present=True)
assert folder.name == name
assert folder.occupied == 0, (
f"new disposable folder unexpectedly contains presets: {folder!r}; "
"refusing to delete its contents")

# Do not retry this write if it errors or times out. The test makes a
# fresh listing check below and reports the exact folder if it remains.
delete_attempted = True
qc.delete_setlist(name)
_wait_for_folder(qc, key, present=False)
deleted = True
finally:
if create_attempted and not delete_attempted:
# CREATE may have committed before an error. Cleanup is allowed only
# after a fresh listing confirms this exact unique folder is empty.
folder = _folder_named(qc, key)
if folder is not None:
if folder.occupied != 0:
pytest.fail(
f"created disposable folder {key!r} now contains "
f"{folder.occupied} presets; left untouched for manual review")
qc.delete_setlist(name)
_wait_for_folder(qc, key, present=False)
deleted = True
if delete_attempted and not deleted:
# An uncertain DELETE is never replayed: tell the operator exactly
# which empty test folder may need manual cleanup.
folder = _folder_named(qc, key)
if folder is not None:
pytest.fail(
f"DELETE result was not confirmed; disposable folder "
f"{key!r} still appears in the fresh listing and was not "
"deleted a second time")
26 changes: 26 additions & 0 deletions tests/test_client.py
Original file line number Diff line number Diff line change
Expand Up @@ -3150,11 +3150,24 @@ def test_create_setlist_uses_a_sibling_path_under_the_presets_root():
assert path == "/media/p4/Presets/probe"
folder = qc._t.sent[-1].folder
assert folder.key == "/media/p4/Presets/probe"
assert not folder.HasField("parent_key")
assert folder.name == "probe"
assert folder.is_factory is False
assert "My Presets" not in folder.key, "a setlist is not nested inside My Presets"


def test_coros_4_1_create_setlist_builder_serialization_pin():
from pyquadcortex.protocol.profiles import QuadCortex41
qc = QuadCortex41(FakeTransport())
qc.create_setlist("Tour")
msg = qc._t.sent[-1]
assert msg.SerializeToString() == bytes.fromhex(
Comment thread
tony-xmelon marked this conversation as resolved.
Resolved
"18 00 22 33 0a 16 2f 6d 65 64 69 61 2f 70 34 2f 50 72 65 73 65 74 "
"73 2f 54 6f 75 72 12 11 2f 6d 65 64 69 61 2f 70 34 2f 50 72 65 73 "
"65 74 73 1a 04 54 6f 75 72 20 00"
)


# -- master volume, pinning, setlist deletion ----------------------------------


Expand Down Expand Up @@ -3204,6 +3217,19 @@ def test_delete_setlist_addresses_the_folder_key():
assert msg.action == pa.MessageAction.DELETE
assert msg.folder.key == "/media/p4/Presets/probe"
assert msg.folder.name == "probe"
assert not msg.folder.HasField("is_factory")
assert not msg.HasField("delete_from_library")
assert not msg.HasField("to_folder")


def test_coros_4_1_delete_setlist_builder_serialization_pin():
from pyquadcortex.protocol.profiles import QuadCortex41
qc = QuadCortex41(FakeTransport())
qc.delete_setlist("Tour")
assert qc._t.sent[-1].SerializeToString() == bytes.fromhex(
"08 02 18 00 22 1a 0a 16 2f 6d 65 64 69 61 2f 70 34 2f 50 72 65 73 "
"65 74 73 2f 54 6f 75 72 20 00 50 00"
)


def test_looper_state_enum_omits_the_value_never_observed():
Expand Down
2 changes: 0 additions & 2 deletions tests/test_hardware_markers.py
Original file line number Diff line number Diff line change
Expand Up @@ -35,8 +35,6 @@
"read_preset": "the undo/redo test reads its scratch copy to choose an edit, but verifies the history result rather than the read operation itself",
"find_preset": "no hardware test yet; it needs a name that exists on the unit, which the loaded preset supplies - just not written",
"wait_for_listing": "no hardware test yet; it is the polling wrapper round list_presets and says nothing until a write changes a listing",
"create_setlist": "no hardware test yet; it adds a folder to the owner's Directory that a failed run would leave behind",
"delete_setlist": "no hardware test yet; it deletes a folder and everything in it, which is the one preset-library mistake nothing can undo",
"duplicate_setlist": "no hardware test yet; it copies a whole setlist preset by preset, so a failed run leaves a partial folder behind",

# -- library listings whose CONTENT belongs to the owner -------------------
Expand Down
6 changes: 4 additions & 2 deletions tests/test_profiles.py
Original file line number Diff line number Diff line change
Expand Up @@ -253,13 +253,15 @@ def test_the_4_1_profile_exposes_only_operations_with_contributed_evidence():
assert issubclass(cls, client.QuadCortex)
assert cls.MEASURED_ON == ("4.1.0",)
assert cls.EVIDENCE is support.Evidence.CONTRIBUTED
assert cls.VERIFIED == frozenset({"create_local_backup", "set_device_name"})
assert cls.VERIFIED == frozenset({
"create_local_backup", "create_setlist", "delete_setlist",
"set_device_name"})
assert cls.CC_VERSION == "4.0.1", "inherited: the contributor's runs announced 4.0.1"
assert isinstance(cls.models, support.NoSnapshot)
with pytest.raises(AttributeError, match="coros_4_1_0"):
cls.models.Delay
assert cls(FakeTransport()).unverified_operations == (
client.QuadCortex.operations() - {"create_local_backup", "set_device_name"}
client.QuadCortex.operations() - cls.VERIFIED
)


Expand Down
Loading