diff --git a/docs/protocol.md b/docs/protocol.md index c75a37e..9c10a6a 100644 --- a/docs/protocol.md +++ b/docs/protocol.md @@ -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: "", - is_factory: false}} +CorOS 4.0.1: +File{CREATE, type: 0, folder{key: "/media/p4/Presets/", name: ""}} + +CorOS 4.1.0: +File{CREATE, type: 0, folder{key: "/media/p4/Presets/", + parent_key: "/media/p4/Presets", + 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` @@ -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}}` | 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 | diff --git a/pyquadcortex/protocol/client.py b/pyquadcortex/protocol/client.py index 39f7298..fba3d08 100644 --- a/pyquadcortex/protocol/client.py +++ b/pyquadcortex/protocol/client.py @@ -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}" diff --git a/pyquadcortex/protocol/profiles.py b/pyquadcortex/protocol/profiles.py index 0a3dd76..bd4edce 100644 --- a/pyquadcortex/protocol/profiles.py +++ b/pyquadcortex/protocol/profiles.py @@ -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 @@ -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. diff --git a/tests/hardware/test_setlist_operations.py b/tests/hardware/test_setlist_operations.py new file mode 100644 index 0000000..8792cc1 --- /dev/null +++ b/tests/hardware/test_setlist_operations.py @@ -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") diff --git a/tests/test_client.py b/tests/test_client.py index 5b35eda..780ec4d 100644 --- a/tests/test_client.py +++ b/tests/test_client.py @@ -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( + "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 ---------------------------------- @@ -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(): diff --git a/tests/test_hardware_markers.py b/tests/test_hardware_markers.py index c8b4db5..89ab709 100644 --- a/tests/test_hardware_markers.py +++ b/tests/test_hardware_markers.py @@ -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 ------------------- diff --git a/tests/test_profiles.py b/tests/test_profiles.py index 98ef98d..6f260c3 100644 --- a/tests/test_profiles.py +++ b/tests/test_profiles.py @@ -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 )