diff --git a/CLAUDE.md b/CLAUDE.md index 9907dd3..2dd05e1 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -9,12 +9,13 @@ Read `docs/STEERING.md` before non-trivial work (new operations, transport or fr - Dev setup: `uv venv && uv pip install -e ".[dev]"` (or plain venv + pip, see contributing.md). Run tests with `.venv/bin/python -m pytest`. The suite passes offline - no hardware, no `hid` import, no `DYLD_LIBRARY_PATH`. - Two namespaces, one package (ADR-0006): `pyquadcortex` is the model of the unit, `pyquadcortex.protocol` is the message-level API. The model's code lives in `pyquadcortex/device/` - not `model/`, because in this codebase the identifier `model` means an amp or pedal block (`protocol/models.py`, `catalog.Model`, `ModelCatalog`, `set_block(model=...)`). The model imports the protocol layer; nothing under `pyquadcortex/protocol/` may import from `pyquadcortex/device/`. -- Every conversion between a screen value and a wire value lives in `pyquadcortex/device/translate.py` and nowhere else in the package outside `pyquadcortex/protocol/` - the whole package, not just `device/`, because a rule scoped to a directory is satisfied by moving the code one directory up. It covers rows 1-4, slots 1-8, scene and footswitch letters, preset addresses and display units. Outside the boundary that means no `+1`/`-1` on a coordinate AND none of the other spellings of the same conversion (`ord`/`chr`, a letter table in any container, `divmod` on a position, a one-based `enumerate`, `ROWS.index(...)`), and no module reaching past the boundary for a protocol-layer name that carries a coordinate or a raw scale - the converters and also the readers that hand back wire indexes, such as `protocol.stomp_assignments`. `tests/test_translation.py` reads the source and proves both, and pins where each check stops seeing rather than implying it sees everything. A model API takes `FootswitchLetter`, never a bare footswitch integer, because a footswitch index and a block's column are different numbers that usually agree. A new conversion goes in that module with its own test, however small it is, and its protocol-layer name joins that file's allowlist in the same commit. +- Every conversion between a screen value and a wire value lives in the `pyquadcortex/device/translate/` PACKAGE and nowhere else in the package outside `pyquadcortex/protocol/` - the whole package, not just `device/`, because a rule scoped to a directory is satisfied by moving the code one directory up. It covers rows 1-4, slots 1-8, scene and footswitch letters, preset addresses and display units. Outside the boundary that means no `+1`/`-1` on a coordinate AND none of the other spellings of the same conversion (`ord`/`chr`, a letter table in any container, `divmod` on a position, a one-based `enumerate`, `ROWS.index(...)`), and no module reaching past the boundary for a protocol-layer name that carries a coordinate or a raw scale - the converters and also the readers that hand back wire indexes, such as `protocol.stomp_assignments`. `tests/test_translation.py` reads the source and proves both, and pins where each check stops seeing rather than implying it sees everything. The boundary is a package, so its exemption covers a DIRECTORY: `BOUNDARY_MODULES` in that file names the modules inside it, and adding `translate/anything.py` has to come through that list with a reason - otherwise the arithmetic scan skips the new file for the same reason it skips the real converters. The same file now carries a second list, `PROTOCOL_NON_CONVERSIONS`, for protocol-layer names the boundary uses that are NOT conversions (`field_present`, the port enums). It exists because the boundary reads whole presets now, so "everything it reaches for is a conversion" stopped being true; a name has to be on one list or the other, and which one is a judgement a reviewer makes from the reason written beside it. A model API takes `FootswitchLetter`, never a bare footswitch integer, because a footswitch index and a block's column are different numbers that usually agree. A new conversion goes in that module with its own test, however small it is, and its protocol-layer name joins that file's allowlist in the same commit. - The model represents what the unit shows, in the unit's own words, and never guesses. A control we understand but cannot yet drive is modelled and REFUSES the operation (ADR-0007); a control we do not understand is omitted, with the reason recorded in `docs/domain-model.md`'s appendix. Nothing ships with a "this might be stale or wrong" caveat. - A model property that reads a device field checks the field is PRESENT (`protocol.field_present`) before reporting it. Most of this schema sits in synthetic `oneof`s, so protobuf returns `""` or `0` for a field the unit never sent, and reporting that as the answer is the guess the rule above forbids. `device/state.py` does this structurally - an absent field is simply not in the entry's copy - so a property reads through it rather than checking by hand. The exception is a field the schema gives no presence at all, where absent and default are the same bytes: those are declared in the entry's `FieldPlan.no_presence` WITH the recorded evidence for what the default means, and `tests/test_state.py` holds the declaration against the schema. Never assume; declare. +- The cache holds a COPY of any submessage it keeps, never the container the RX thread decoded - that one is shared with every other listener and read afterwards from other threads. `entries._held` does it; `tests/test_state.py` proves it by mutating the source afterwards. - Never cache an incomplete reply AS IF IT WERE COMPLETE. Per field is the rule: the cache keeps what the unit actually sent and re-reads for what it did not, so a retry recovers the missing half without re-asking for the half already answered. What must never happen is a field the unit never sent being handed back as a value. - Model state lives in `device/state.py`, and what is tracked is a `StateEntry` in `device/entries.py` - not an attribute a property fills in itself (ADR-0011). Pushes MERGE (an absent field means "not mentioned", never "reset to default"); a read REPLACES, because it is the unit's whole answer. A message that sets any field the entry does not keep - a schema field or a field number the bindings have never heard of - marks that entry for one re-read; there is no "harmless field" category, and adding one is a guess with a table around it. A message type no entry tracks is ignored outright, which is what makes the metronome's tempo stream free. -- A new entry decides for itself what `action` means on the types that feed it. The shared `SCAFFOLDING` skip covers it today because the two tracked types give it no meaning, and that is NOT true of `Grid`, where `action: DELETE` is what removes a block and an `UPDATE` with the same payload does nothing. Never widen `SCAFFOLDING` to make a new entry quiet. +- A new entry decides for itself what `action` means on the types that feed it. The shared `SCAFFOLDING` skip covers the plain entries because `action` gives them no meaning, and that is NOT true of `Grid`, where `action: DELETE` is what removes a block and an `UPDATE` with the same payload does nothing. Never widen `SCAFFOLDING` to make a new entry quiet. `Grid` made its decision and it is `FieldPlan(invalidates=True)`: `action` does not matter, because every `Grid` push means the grid moved, and the entry re-reads rather than merging (ADR-0012). `invalidates` is also the answer where the per-field check is BLIND - `SceneLabel` gives `index` and `label` no presence, so renaming a scene to a blank label sets nothing at all in `ListFields()`. A push carrying every field an entry keeps clears the mark, because that is what a read returns; judge it on what the message carried, not on what its plan could carry. - Anything the model caches is valid only while its connection is. A closed `Device` refuses reads rather than answering from cache, because a model that reports the unit's state through an object with no unit behind it is the failure the whole layer exists to avoid. `Device.close()` closes the state layer first, so a `Device` built by `from_client` stops listening on a connection it never owned. - `import hid` appears exactly once, lazily, inside `session.open_device()`. Never import `hid` at module scope. A new module that needs it imports it inside the function that opens the device; `tests/test_import_cleanliness.py` walks the whole package and proves it. - Never gitignore or delete `pyquadcortex/protocol/proto/*_pb2.py` - the generated bindings are committed on purpose (ADR-0001, written before the proto directory was moved). Regenerate only via `scripts/compile_protos.sh`, and bump the `protobuf` pin in `pyproject.toml` in the same commit as regenerated bindings. The `grpcio-tools` floor in the dev extra is part of that same commit: `grpcio-tools` carries its own protoc, so the installed version decides the gencode, and an older one emits older gencode that still imports and quietly walks the pin backwards (ADR-0008). Both directions are now guarded - the script refuses to write a downgrade, and `tests/test_packaging.py` proves the committed gencode equals the pin floor - so trust the failure and fix the cause rather than working around either. Never read the floor off `grpcio-tools` metadata; 1.82.1 declares `protobuf>=7.35.1` and emits 7.35.0. Run the compiler and read the stamp. CI's `build` job runs `scripts/check_artifacts.py`, which proves the bindings are inside the wheel and the sdist. diff --git a/docs/ADR.md b/docs/ADR.md index 144b5a4..931036a 100644 --- a/docs/ADR.md +++ b/docs/ADR.md @@ -166,3 +166,39 @@ Records are append-only once `Decided` and built upon: a shipped decision is nev - A write the unit CONTRADICTS marks the entry too, which section 10 does not ask for. Section 10 asks for a log line, on the reasoning that a disagreement is a bug in our code rather than a stale cache. That is true of the field the unit named, whose value the echo has just put right. It is not true of the other fields in the same write: those went into the cache on our say-so, the echo did not carry them, and the write they belonged to is one the unit has just demonstrated it disagreed with. Leaving them there makes the one path that means "we have a bug" the one that cleans up after itself least, so it marks - which costs one read on a path that should never run. - `action` and `request_id` are skipped on every entry, and only one of them is really the transport's. `request_id` always is. `action` is not: on `Grid` it is load-bearing state, because an `UPDATE` carrying `hash: 0` is transmitted and ignored while the same payload with `action: DELETE` removes the block. It is skipped today because the two tracked message types give it no meaning, and a `Grid` entry (issue #12) therefore cannot inherit the skip - two pushes with identical payloads and opposite meanings would apply identically and mark nothing. That entry gives `action` its own decision rather than widening the shared set. - A field the wire gives no presence and the entry does not KEEP is undetectable rather than merely unkept: proto3 writes such a field only when it differs from its default, so a message leaving one at its default carries no bytes for it and no implementation of this check could see it. `tests/test_state.py` asserts no feeding type has one, which turns a limit of the wire into a question about our own code, checkable and checked. + +## ADR-0012: A grid push is noted and re-read, not merged, and the model publishes what it noticed + +- **Status:** Decided (2026-08-15) +- **Decision:** A `Grid`, `SceneLabel` or `SceneColor` push makes the preset entry's copy untrusted **whatever the message carries**, and the next read fetches the whole live preset with `RecallPreset{READ}`. The model does not merge those deltas. Alongside it, the model publishes two events on `device.events` - `Changed` when a push moved a value it holds, `Invalidated` when it stopped trusting part of its copy - delivered on a thread the model owns, so a subscriber may read from the unit in response. +- **Context:** One edit on the touchscreen produces about forty `Grid` pushes, each a sparse keyed delta into a deeply nested preset payload. ADR-0011 requires the model to notice anything a message names that it does not represent, and for a delta that means walking the payload recursively rather than reading `ListFields()` at the top level. Two of these types also defeat the per-field check outright: `Grid` carries its meaning in `action`, which the wire gives no presence, so an `UPDATE` and a `DELETE` with identical payloads are indistinguishable; `SceneLabel` gives `index` and `label` no presence either, so renaming a scene to a blank label sets nothing observable at all. Without merging, a caller who needs a fresh value promptly has no way to learn that it went stale, because re-reading only happens when somebody reads a property. +- **Options:** + - **(a) Note it and re-read the whole preset, and publish what was noticed - chosen.** Forty pushes cost one read, because the note is a flag rather than a queue, and `RecallPreset{READ}` has no side effects on the grid, the active scene or the audio. A caller who cannot wait subscribes. + - **(b) Merge each delta by key.** Chain by row, model by column, parameter by index, plus the recursive version of ADR-0011's check to stay honest. Reads stay instant while somebody edits on the unit. Rejected for M1 because the recursive check is where all of the risk sits, and it would have landed beside the objects three other stories are blocked on. What it would take is recorded in `domain-model.md` section 9 so this stays a decision rather than an omission. + - **(c) Treat `action` as scaffolding on `Grid` like everywhere else.** Two pushes with identical payloads and opposite meanings would apply identically and mark nothing. This is the failure ADR-0011's consequences already name. + - **(d) Deliver events on the RX thread.** No new thread, and the same contract as `Transport.add_listener`. Rejected because ADR-0009 forbids reading from that thread, which makes the feature unusable for the thing it exists for: a subscriber's obvious reaction is to go and read the value. +- **Open Questions:** Whether merging is worth doing once parameters land (#13), since a caller stepping through parameter values during an on-unit edit pays a whole-preset read per step. Measurable rather than arguable: the forced re-reads are logged with the entry that caused them. Also whether `Invalidated` firing only on the trusted-to-untrusted transition is the right shape for a subscriber that never reads - today the stream goes quiet until somebody does, which is documented but not hedged. +- **Rationale:** The two halves answer the same question from opposite ends. Not merging means the model never has to understand a delta to stay correct, which is what makes the conservative rule affordable; publishing means the cost of not merging - a round trip on first access after an edit - is one a caller can choose to pay eagerly instead. The thread is not a convenience: it is the difference between an event a caller can act on and one that raises when they try. +- **Consequences:** + - The first property read after an on-unit edit is a round trip. A merging cache would have answered instantly. + - A push that carries every field an entry keeps clears the mark, because that is exactly what a read returns. This is what makes the connect burst leave the cache genuinely warm: measured, it marks two entries and answers both in full a millisecond later. The check is on what the MESSAGE carried, not on what its plan could carry - a plan-level version was written first and every branch of it was unreachable. + - The model owns a thread whose contract is the opposite of the RX thread's: reading from the device is allowed there and expected. It is started on the first subscriber and stopped by `Device.close`, and it catches `BaseException` around a subscriber for the reason `Transport._notify_listeners` records. + - "A recall resets three things together" is declared once, on the entry that knows which slot is loaded, and fires only when that slot really changes. Declaring it as a plan on each dependent entry instead made the model's own read of the loaded slot look like a recall, and publish two events saying the unit had changed. + +## ADR-0013: The translation boundary is a package, and what it may reach for is two lists + +- **Status:** Decided (2026-08-15) +- **Decision:** `pyquadcortex/device/translate.py` becomes a package, split by responsibility, with every public name re-exported so no caller changes. The structural test that proves no other module converts now exempts a DIRECTORY, so it names the package's modules and keys that list on the path within the package. The derived check that kept the protocol-conversion allowlist current is **weakened**: a protocol-layer name the boundary uses must appear on one of two lists - `PROTOCOL_CONVERSIONS`, which no other module may use, or `PROTOCOL_NON_CONVERSIONS`, which carries a written reason per entry. +- **Context:** Reading a whole preset in screen coordinates is boundary work: the protocol helpers it needs (`blocks`, `splits`, `bypass_state`) hand back raw wire coordinates, so only the boundary may call them. Adding that to the existing module took it past 800 lines. The derived check said "whatever the boundary reaches for IS a conversion, because converting is all it does", which needed no list to maintain. That premise held while the boundary converted single values and stopped holding the moment it read messages: reading needs `field_present`, which the root `CLAUDE.md` REQUIRES every model property to call, and the `Input`/`Output` port enums, which are public model API. Under the old rule both would have been banned. +- **Options:** + - **(a) Two lists, one derived check over their union - chosen.** The direction that actually rots is preserved: a new conversion the boundary starts calling cannot arrive silently, because it must be accounted for before the suite goes green. + - **(b) Keep the single derived rule and ban `field_present`.** Directly contradicts a root `CLAUDE.md` rule. Rejected. + - **(c) Put the grid readers outside the boundary and exempt them.** That is the guard-narrower-than-it-reads mistake this project keeps catching, and it would have exempted exactly the code most worth watching. + - **(d) Keep one file.** Rejected on size, and it would not have helped: the two-list problem is caused by what the boundary now DOES, not by where it lives. +- **Open Questions:** Whether `PROTOCOL_NON_CONVERSIONS` should be scoped to the boundary rather than package-wide. Its only effect today is absence from the ban list, so every model module may use those names - including `Output.NEXT_ROW_3`, whose row number is precisely the inference `translate.routes_to_a_row` refuses to make on the record. Nothing in the model does this, and no test would catch it. +- **Rationale:** A rule that contradicts another rule is not a stronger rule, it is a broken one, and the honest fix was to say what the second category is rather than to bend the first. The residual risk is named where a reviewer will meet it: a real conversion parked on the wrong list is the one failure no test can catch, so the criterion - what does the name HAND OVER? - is written beside both lists. +- **Consequences:** + - Adding a module to `translate/` requires adding it to `BOUNDARY_MODULES` with a reason, in the same commit. The list is keyed on the path within the package, because a filename-keyed version let `translate/legacy/grid.py` pass as "grid" while the arithmetic scan skipped it. + - A reviewer now has judgement to apply where previously there was none: when a name appears, which list it belongs on. + - `scripts/check_artifacts.py` names the package's `__init__` AND a real converter module, because a packaging rule that took the directory but dropped its contents would ship a boundary that re-exports names it no longer has. + diff --git a/docs/STEERING.md b/docs/STEERING.md index c233840..13d763e 100644 --- a/docs/STEERING.md +++ b/docs/STEERING.md @@ -59,7 +59,7 @@ The model layer holds the state (design in [`domain-model.md`](domain-model.md) | Fake-per-layer offline tests | Each layer has a purpose-built double: golden captured frames for `framing`, `FakeHid` for `transport`, `FakeTransport` for `client` | see ADR-0002 | `FakeTransport` in `tests/test_client.py` | Hardware verification happens manually via `examples/`, outside the suite | | Evidence-bearing docstrings | Each operation's docstring states what is confirmed on hardware vs inferred from the schema | The device gives no errors for wrong writes, so recorded evidence is the only trail | `QuadCortex.read_preset` in `pyquadcortex/protocol/client.py` | Non-protocol helpers (pure functions) carry ordinary docstrings | | Keyed grid edits | Mutations are row/column-keyed `Grid` UPDATEs | The device applies grid updates by key; wholesale preset writes are silently ignored (see [`architecture.md`](architecture.md), "write_preset is a trap") | `QuadCortex.set_bypass` in `pyquadcortex/protocol/client.py` | Read paths, and non-grid operations | -| One translation boundary | Screen values become wire values in exactly one module, and a source-reading test proves no other module in the package does it - the whole package outside `protocol/`, not just `device/` | An off-by-one row is silent - the write lands on a real row and reads back perfectly - so a convention cannot be trusted to hold (design principle 5 in [`domain-model.md`](domain-model.md)) | `pyquadcortex/device/translate.py` | The protocol layer, which keeps its zero-based indexes and raw scales | +| One translation boundary | Screen values become wire values in exactly one PACKAGE, and a source-reading test proves no other module in the package does it - the whole package outside `protocol/`, not just `device/`. The exemption covers a directory, so a test names the package's modules and a new one has to come through that list | An off-by-one row is silent - the write lands on a real row and reads back perfectly - so a convention cannot be trusted to hold (design principle 5 in [`domain-model.md`](domain-model.md)) | `pyquadcortex/device/translate/` | The protocol layer, which keeps its zero-based indexes and raw scales | | Model state goes through the cache | A model property reads `Device.state.value(entry, field)`; what it tracks is a `StateEntry` in `device/entries.py`, not an attribute the property fills in itself | One account of what the model believes and how it learned it. A property with its own cached attribute answers from a copy nothing invalidates, and a closed connection cannot take it away (see ADR-0011) | `Device.firmware` in `pyquadcortex/device/device.py` | Values derived from an entry rather than read from the unit, which compute from `value()` rather than caching alongside it | ## 6. Constraints @@ -166,6 +166,28 @@ caveat. Story OM-M1.3 (#11), Epic #8. grid and parameters (#12 and after), and the counters and event taxonomy (#16). The log events this code emits are named for #16 to pick up, but nothing reads them yet. +### 2026-08-15 - ADR-0012: a grid push is re-read, not merged; the model publishes what it noticed + +**What changed:** +- ADR.md: added ADR-0012 (a `Grid` or `SceneLabel` push voids the entry's copy and the next read fetches the whole live preset; `device.events` carries `Changed` and `Invalidated` on a thread the model owns) +- domain-model.md: §9 gains the by-type rule, the no-merge decision with what merging would take written beside it, the complete-push rule, and the event surface; §2 and §3 record what is built and the four things deliberately omitted; the §9 table is corrected against hardware +- CLAUDE.md: the translation boundary is a package with a named module list and a second allowlist for non-conversions; `Grid`'s `action` decision is recorded; submessages are cached by copy +- STEERING.md: the "One translation boundary" pattern row now says package +- architecture.md: the module map gains `preset.py`, `grid.py`, `blocks.py`, `events.py` and `errors.py`, and `translate.py` becomes `translate/` +- protocol.md: `read_current_preset_push` and `loaded_position` added to the coverage table, with the measured shape of a recall and of the connect burst + +**Why:** +- A hardware session contradicted three assumptions this work had been built on - the burst's seed preset push carries `reason`, a recall pushes no `PresetDirty`, and `SetlistPosition{READ}` really does answer - and the corrections belong where the next person meets them rather than in a commit message + +**Scope of impact:** +- **Updated:** ADR.md, domain-model.md, CLAUDE.md, STEERING.md, protocol.md +- **Also updated:** architecture.md - the module map gains the five new model modules and `translate/` is a package there too +- **Not updated (intentionally):** api.md - it documents the protocol layer, whose two additions are in protocol.md's coverage table, and the model's surface is documented in domain-model.md + +**Downstream to consider:** +- The Directory half of issue #12 needs `StateEntry` to carry how many messages a read expects, since a setlist listing answers with several hundred +- Whether merging grid deltas is worth doing is now a recorded question rather than an omission; #13's parameter work is the first thing that would feel the cost + ### 2026-08-13 - One translation boundary, and the model package is `device/` **What changed:** diff --git a/docs/architecture.md b/docs/architecture.md index 8a32f7c..4c59e06 100644 --- a/docs/architecture.md +++ b/docs/architecture.md @@ -38,7 +38,7 @@ only about the layer directly below it. pyquadcortex/ THE MODEL - what import pyquadcortex hands back device/device.py connect(): opens the unit, returns a Device. | Speaks the unit's vocabulary, never the wire. - | (Directory and grid land in later stories.) + | (The Directory lands in a later story.) | device/state.py The write-through cache every model read goes | through: applies what the unit pushes, asks for @@ -52,9 +52,25 @@ only about the layer directly below it. device/watch.py Whether a write landed: the echo watcher and its | three outcomes, plus the one thread that gives up | - device/translate.py Screen values <-> wire values, and the ONLY place + device/preset.py The preset on the grid: its name, its scenes, and + | whether it is still the loaded one + | + device/grid.py Four rows of eight slots, and the two ways to look + | at them - live-bound to the active scene, or fixed + | + device/blocks.py What sits in a cell: a virtual device, the ends of + | a row, a splitter, a mixer + | + device/events.py What the model noticed, for a caller who wants to + | know as it happens - on a thread of its own + | + device/errors.py The refusals that mirror something the unit cannot + | do + | + device/translate/ Screen values <-> wire values, and the ONLY place | either becomes the other: rows, slots, scene and - | footswitch letters, preset addresses, display units + | footswitch letters, preset addresses, display + | units, and a whole preset renumbered for the screen | | -- the model/protocol seam -- | @@ -248,7 +264,7 @@ sent must come back with the value we sent. Not "the echo equals what we sent" - the unit legitimately changes things nobody asked about. One watchdog thread for the whole connection, started on the first write and never before it. -### device/translate.py +### device/translate/ The model speaks what the touchscreen shows - rows 1 to 4, slots 1 to 8, scenes and footswitches as letters, dB, Hz, bpm, ms - and the wire speaks zero-based indexes @@ -256,7 +272,15 @@ and raw scales. Every conversion between the two lives here and nowhere else in `pyquadcortex/` outside `protocol/` - the whole package, not just the model directory (design principle 5 in [domain-model.md](domain-model.md)). -One module rather than a convention, because the mistake it prevents is silent. +A package, split by responsibility: `guards`, `coordinates`, `letters`, +`addresses`, `units`, and `grid` for reading a whole preset in screen numbers. +Every public name is re-exported, so `translate.row_to_wire(...)` resolves as it +always did. Because the source-reading test exempts the whole directory, +`tests/test_translation.py` names the modules inside it - a new one has to come +through that list, or the arithmetic scan would skip it for the same reason it +skips the real converters. + +One place rather than a convention, because the mistake it prevents is silent. This document's own layer map sits above a protocol layer whose header says it: a write to the wrong row lands on a real row and reads back perfectly, so nothing tells the caller. Collecting the arithmetic in one place makes it reviewable in diff --git a/docs/domain-model.md b/docs/domain-model.md index 391d2a6..2fa59fb 100644 --- a/docs/domain-model.md +++ b/docs/domain-model.md @@ -39,7 +39,7 @@ 5. **One translation boundary.** The model speaks touchscreen coordinates and display units everywhere. Conversion to protocol values (0-based indexes, raw scales) happens in exactly one module at the model-to-protocol seam. No `-1`/`+1` anywhere else. - **Built:** `pyquadcortex/device/translate.py`, with the rule enforced by a test that + **Built:** the `pyquadcortex/device/translate/` package, with the rule enforced by a test that reads the source of the whole package outside `protocol/` - not just the model directory - rather than trusting a convention. @@ -153,7 +153,7 @@ class PresetAddress: > with the mode - linear position 5 reads "1F" normally and "2B" under the hybrid - so an > address is only unambiguous alongside the mode it was read in. -> **`PresetAddress` is built**, in `pyquadcortex/device/translate.py` and exported from +> **`PresetAddress` is built**, in `pyquadcortex/device/translate/` and exported from > `pyquadcortex`. It speaks the non-hybrid naming, "A".."H". `PresetAddress.parse("28C")` > refuses a malformed address there and then, rather than at write time, and `.to_wire()` > / `.from_wire()` convert through the protocol layer's own `slot_to_position` pair so the @@ -262,6 +262,30 @@ class Scene: def activate(self) -> None: ... # audible - explicit, like recall ``` +> **Sections 2 and 3 are built**, less the parts that need the Directory or a write. +> `device.preset`, `preset.rows`, `row.slots`, `preset.blocks`, `scene.blocks`, +> `scenes.active`, `scene.name`, `scene.activate()`, splits, routing, +> `has_unsaved_changes` and `is_current` all read on hardware - with one gap +> stated rather than glossed: the loaded preset routes no row into another row, +> so the rule that such a row shows no LANE OUTPUT CONTROL is covered offline +> against a recorded payload that does, and the hardware test skips it aloud. Four things named above +> are deliberately NOT built, and each is an omission rather than a caveat (principle 3): +> +> * **`UserPreset` / `FactoryPreset`.** Which one you hold is a Directory fact, and the +> only method that separates them - `save()` - is M2. A type split with nothing in it +> would be shape without meaning, so `device.preset` is a `Preset` until the split +> carries a method. +> * **`preset.instrument`** lives on the directory listing rather than in the preset, as +> §11 says. It arrives with the Directory. +> * **`preset.address`** needs the Directory to say which setlist a position is in. +> `SetlistPosition{READ}` is confirmed and the model tracks the loaded slot already; +> what is missing is the setlist, not the read. +> * **Which row an output feeds.** `Output.NEXT_ROW_3` almost certainly means screen row +> 3 - the unit has four rows and the names fit - but almost certainly is a guess, and a +> wrong row is the silent failure this whole design is arranged against. So +> `output.destination` reads as the port it is, and `output.lane` is absent when that +> port feeds a row, which is what the screen shows and is the part that can be checked. + **The scene/grid duality.** Blocks are placed once per preset; bypass state and scene-following parameter values vary per scene. There is exactly one `Block` object per occupied cell, and a `BlockGrid` is a *binding* of the grid to a scene context: @@ -274,6 +298,13 @@ Scene-invariant facts (which device is placed, its position, its non-scene param are identical through every binding; scene-varying state differs. The two paths cannot disagree because the object underneath is the same. +> **As built, "one object" means one CELL, not one Python object.** Two bindings hand +> back two handles on the same cell. They have to: a single object could not answer +> `bypassed` differently for `preset.blocks` and `sceneB.blocks`, which is the whole +> point of a binding. What the handles share is the payload underneath, so where a block +> is and which device is in it cannot differ between them. They compare EQUAL, and `is` +> is not the test - within one binding the same handle does come back. + Writing through a *non-active* scene's binding is **refused**, because the unit has no way to do it without switching scenes first - which would change what you hear and leave it changed. Reads through such a binding are fine. See @@ -687,7 +718,7 @@ class Stomps: # preset.stomps > eventually passes a column to it and gets a write that silently does nothing, which is > precisely the bug that cost a hardware session to find. > -> **`FootswitchLetter` is built**, in `pyquadcortex/device/translate.py` and exported from +> **`FootswitchLetter` is built**, in `pyquadcortex/device/translate/` and exported from > `pyquadcortex`. It is a `StrEnum`, so `stomps["E"]` and `stomps[FootswitchLetter.E]` are > the same key and it prints as the screen labels it. Passing the number 4 raises, with a > message naming the column trap. `SceneLetter` is the same type for scenes. @@ -777,6 +808,38 @@ The check is per FIELD, not per message type. Applying the half of a message we understand and silently dropping the rest is the one failure mode that leaves the cache confidently wrong, so it is the case this rule exists to catch. +**Two message types are handled by type rather than by field, because the per-field +check cannot see them.** `Grid` carries its meaning in `action`, which the wire gives no +presence, so an `UPDATE` and a `DELETE` with the same payload look identical to a +field-by-field reading. `SceneLabel` gives `index` and `label` no presence either, so +renaming scene A to a blank label sets *nothing at all* that a field check can observe. +Both are therefore declared as voiding the copy outright: every message of those types +means the preset moved, whatever it appears to carry. That is each entry's own decision +about `action`, made per entry and per type; the shared scaffolding skip is not widened. + +**A grid push is not merged.** A `Grid` echo is a sparse, keyed delta into a deeply +nested structure. Rather than apply it, the model notes that the grid moved and re-reads +the whole live preset on the next access. One edit on the touchscreen produces about +forty of these and costs exactly one re-read, because the note is a flag rather than a +queue, and `RecallPreset{READ}` has no side effects. + +What merging would take, if it is ever worth doing: each push applied BY KEY into the +stored payload - chain by row, model by column, parameter by index - and, to stay honest, +the "did this mention something we do not model" check walking that structure recursively +instead of reading the top level. The prize is that reads stay instant while somebody is +editing on the unit. The reason it is not M1 is that the recursive check is where all of +its risk sits, and it would have sat next to the objects three other stories are blocked +on. A caller who needs the fresh value sooner subscribes to `device.events` - below - and +reads it themselves. + +**A push carrying every field an entry keeps clears the mark.** It is the same thing a +read returns, so it replaces rather than merges, and an entry holding the unit's own +complete answer has nothing left to ask about. This is what makes the connect burst leave +the cache genuinely warm rather than nominally warm: measured 2026-08-15, the burst +delivers `RecallPreset`, `SetlistPosition`, `PresetDirty` and `Scene` in that order +inside ten milliseconds, so two entries are marked by one message and answered in full by +the next. + **3. When we write, we update our copy immediately.** The unit echoes our own change back, and that echo confirms it. Because we already applied it, a matching echo changes nothing - one code path, not two. Waiting for the echo before updating would make every @@ -792,11 +855,11 @@ happens when an echo does disagree. | What | Where you read it | How we ask | What tells us it changed | |---|---|---|---| -| The preset on the grid now | `device.preset` | `RecallPreset{READ}` | `Grid`, `RecallPreset` | -| Which scene is active | `preset.scenes.active` | `Scene{READ}` | `Scene` | +| The preset on the grid now | `device.preset` (**built**) | `RecallPreset{READ}` | `Grid`, `RecallPreset` | +| Which scene is active | `preset.scenes.active` (**built**) | `Scene{READ}` | `Scene` | | Scene names and colors | `scene.name` | comes with the preset | `SceneLabel`, `SceneColor` | -| Unsaved edits | `preset.has_unsaved_changes` | `PresetDirty{READ}` | `PresetDirty` | -| Which preset is loaded | `preset.address` | `SetlistPosition{READ}` | `SetlistPosition` | +| Unsaved edits | `preset.has_unsaved_changes` (**built**) | `PresetDirty{READ}` | `PresetDirty`, and a recall - which pushes NOTHING, so it is re-read | +| Which preset is loaded | `preset.is_current` (**built**); `preset.address` needs the Directory | `SetlistPosition{READ}` - confirmed 2026-08-15, 3 ms | `SetlistPosition` | | What is in a setlist | `setlist` iteration | `File{READ}` | `File` | | Recents and favorites | `device.recents`, `.favorites` | `RecentsFavorites{READ}` | `RecentsFavorites` | | I/O, settings, EQ, volume, mode | `device.io` and friends | one READ each | one push each | @@ -807,6 +870,33 @@ The third column is the safety net. Wherever the fourth turns out to be unreliab ask instead of remembering, which is what lets the model honour principle 3 and never hand back a value with a "might be stale" caveat. +### Telling a caller what we noticed + +Re-reading only happens when somebody asks for a value, which is too late for a script +following the unit closely. So the model publishes what it noticed, and a subscriber can +fetch the fresh value itself: + +```python +with pyquadcortex.connect() as device: + device.events.subscribe(print) +``` + +Two events, both about the model's copy rather than about the wire. `Changed(part, +fields)` when a push moved a value we hold, and `Invalidated(part, why)` when we stopped +trusting our copy of something. Two rules keep the stream usable: `Invalidated` fires on +the change from trusted to untrusted, so one edit on the touchscreen produces one event +rather than forty, and `Changed` fires only when a value really moved, so the unit +restating what it has already said is silent. + +**A subscriber runs on a thread the model owns, and may read from the unit.** That is the +whole reason the thread exists. Messages arrive on the RX thread, which may not read +([§9](#9-how-the-model-keeps-its-facts-current) rule 5, ADR-0009), so handing an event +over there would make the obvious reaction - go and re-read it - raise. The RX thread +queues; the model's thread delivers. The costs are ordinary and worth stating: an event +can lag by however long the subscribers ahead of it take, they are served one at a time +in subscription order, and a subscriber that blocks forever holds up the ones behind it. +None of that can delay the unit. + ### Smaller decisions 1. **Connecting already warms almost everything, so there is little to fetch.** The @@ -1332,3 +1422,25 @@ the n/a rows below where they intersect the API at all. block `model` in code (`models.py`, `Model`, `ModelCatalog`) and will keep doing so, whatever §5 renamed the concept to in this document. No design changed here; this records what is now code. +- **2026-08-15** - M1 story #12 lands the preset surface: `device.preset`, the four + rows, the eight slots, blocks, splits, routing, the eight scenes and both grid + bindings, plus `device.events`. Issue #12 was SPLIT on the way in - the Directory half + needs the cache to handle a read that answers with a STREAM of several hundred + messages, which `StateEntry` says in as many words it does not carry yet, and that work + should not sit beside the objects three other stories are blocked on. + + A hardware session corrected three things this document had implied. The connect burst's + seed `RecallPreset` DOES set `reason`, so an entry that drops it is marked stale by the + very burst that warmed it. A recall pushes `Grid`, `RecallPreset`, `Scene` and + `SetlistPosition` and **no `PresetDirty` at all**, so the unsaved-changes flag has to be + re-read after a recall rather than waited for - §9's "recalling resets three things + together" is right about the unit and needed spelling out for the model. And + `SetlistPosition{READ}` really does answer, in 3 ms, which this document's own table + claimed and nobody had checked; the model tracks the loaded slot from it rather than + counting recall events, so `is_current` compares a fact the unit stated. + + §9 gains the rules those needed: two message types are handled by type rather than by + field because the per-field check cannot see them, a grid push is noted rather than + merged with what merging would take written down beside it, and a push carrying every + field an entry keeps clears the mark the way a read does. §2 and §3 record what is built + and, more usefully, the four things deliberately left out and why. diff --git a/docs/protocol.md b/docs/protocol.md index 44f5e11..bb4a9ff 100644 --- a/docs/protocol.md +++ b/docs/protocol.md @@ -1898,6 +1898,35 @@ a write that applied and was later reset. **`Scene{READ, request_id}` answers with `selected_scene`**, echoing the request id. Confirmed live by switching scenes between reads. `active_scene()` wraps it. +**`SetlistPosition{READ, request_id}` answers with `folder_key`, `position` and +`is_factory`**, echoing the request id. Measured 2026-08-15 on d14e: answered in 3 ms on +the first attempt. `loaded_position()` wraps it. Note this is the same message type as a +recall and the action is the whole difference between asking which slot is loaded and +loading one, so a READ here must never carry a slot. + +The unit also pushes one unsolicited in the connect burst and one on every recall, so a +state tracker can subscribe rather than poll. **The burst's push carries no `request_id`**, +which is why a read has to correlate on the id rather than take the first one that arrives. + +**What a recall really pushes**, measured 2026-08-15 across two host recalls, all within +about 120 ms of the request and in this order: + +| | | +|---|---| +| `Grid` x 8 to 13 | the new grid, block by block | +| `RecallPreset` | the whole new preset, with `reason` | +| `Scene` | the new active scene | +| `SetlistPosition` | which slot is loaded now | + +and **no `PresetDirty` at all**. A recall discards unsaved edits and the unit says nothing +about it, so anything tracking the dirty flag has to re-read it after a recall rather than +wait to be told. + +**The connect burst delivers the same four**, at about 10.0 s, inside ten milliseconds, in +the order `RecallPreset`, `SetlistPosition`, `PresetDirty`, `Scene` - and with no `Grid` +pushes, because nothing changed. The seed `RecallPreset` sets `action`, `preset` and +`reason`. + **`read_preset()` RECALLS the slot it reads** - that was already documented - and the recall **resets the active scene to the preset's default, discards unsaved edits, and interrupts the audio**. @@ -2590,6 +2619,8 @@ visually on the device's own screen. | connect handshake | `ResetCommsBuffers` + `Version` UPDATE + `ModelRepo` READ + `Connection` + subscribe READs | read-back | the connect gate; state pushes flow only after it | | version read | `Version{action: READ}` | read-back | serial and firmware returned | | `recall_preset` / `read_preset` | `SetlistPosition{UPDATE, folder_key, position, is_factory, request_id}` then a `RecallPreset` push | read-back | the push echoes the recall's `request_id` | +| `read_current_preset` / `read_current_preset_push` | `RecallPreset{READ, request_id}` | read-back | the live grid, no side effects. The push variant hands back the whole reply, which carries `reason` beside the preset | +| `loaded_position` | `SetlistPosition{READ, request_id}` | read-back | which slot is loaded; 3 ms measured. A READ names no slot - an UPDATE that did would recall it | | `list_presets` | `File{action: READ}` then `File{folder{files[] = ProductData}}` | read-back | factory listing gzipped; 256 slots; listings lag a few seconds after a `File` mutation | | `switch_scene` | `Scene{UPDATE, selected_scene}` | on-unit | zero-based | | `set_chain_input` / `reroute_grid_input` | `Grid{UPDATE, preset{chains{row, in_portid}}}` | read-back + on-unit | row-keyed; the only shape that persists input routing | diff --git a/pyquadcortex/__init__.py b/pyquadcortex/__init__.py index 967fbc9..73280da 100644 --- a/pyquadcortex/__init__.py +++ b/pyquadcortex/__init__.py @@ -33,8 +33,15 @@ logging.getLogger(__name__).addHandler(logging.NullHandler()) from pyquadcortex import protocol # noqa: E402 -from pyquadcortex.device import (Device, FootswitchLetter, # noqa: E402 - PresetAddress, SceneLetter, connect) +from pyquadcortex.device import (Block, BlockGrid, Changed, # noqa: E402 + Device, DeviceBlock, FootswitchLetter, + InactiveSceneError, InputBlock, InputSource, + Invalidated, LaneOutput, MixerBlock, + ModelEvent, OutputBlock, OutputDestination, + Preset, PresetAddress, Row, Rows, Scene, + SceneLetter, Scenes, Slots, SplittableRow, + SplitterBlock, VirtualDevice, + WatchOutcome, WriteWatch, connect) from pyquadcortex.protocol import (DeviceLostError, # noqa: E402 DeviceNotFoundError) @@ -45,6 +52,30 @@ "FootswitchLetter", "SceneLetter", "PresetAddress", + "Preset", + "Scene", + "Scenes", + "Rows", + "Row", + "SplittableRow", + "Slots", + "BlockGrid", + "Block", + "DeviceBlock", + "InputBlock", + "OutputBlock", + "SplitterBlock", + "MixerBlock", + "LaneOutput", + "VirtualDevice", + "InputSource", + "OutputDestination", + "ModelEvent", + "Changed", + "Invalidated", + "InactiveSceneError", + "WriteWatch", + "WatchOutcome", "protocol", "DeviceNotFoundError", "DeviceLostError", diff --git a/pyquadcortex/device/__init__.py b/pyquadcortex/device/__init__.py index 9843273..bb76c51 100644 --- a/pyquadcortex/device/__init__.py +++ b/pyquadcortex/device/__init__.py @@ -16,9 +16,28 @@ should import them from. The design is in ``docs/domain-model.md``. """ +from pyquadcortex.device.blocks import (Block, DeviceBlock, InputBlock, + InputSource, LaneOutput, MixerBlock, + OutputBlock, OutputDestination, + SplitterBlock, VirtualDevice) from pyquadcortex.device.device import Device, connect +from pyquadcortex.device.errors import InactiveSceneError +from pyquadcortex.device.events import Changed, Invalidated, ModelEvent +from pyquadcortex.device.grid import BlockGrid, Row, Rows, Slots, SplittableRow +from pyquadcortex.device.preset import Preset, Scene, Scenes +from pyquadcortex.device.watch import WatchOutcome, WriteWatch from pyquadcortex.device.translate import (FootswitchLetter, PresetAddress, SceneLetter) -__all__ = ["Device", "connect", "FootswitchLetter", "SceneLetter", - "PresetAddress"] +__all__ = [ + "Device", "connect", + "FootswitchLetter", "SceneLetter", "PresetAddress", + "Preset", "Scene", "Scenes", + "Rows", "Row", "SplittableRow", "Slots", "BlockGrid", + "Block", "DeviceBlock", "InputBlock", "OutputBlock", + "SplitterBlock", "MixerBlock", "LaneOutput", + "VirtualDevice", "InputSource", "OutputDestination", + "ModelEvent", "Changed", "Invalidated", + "InactiveSceneError", + "WriteWatch", "WatchOutcome", +] diff --git a/pyquadcortex/device/blocks.py b/pyquadcortex/device/blocks.py new file mode 100644 index 0000000..74fc591 --- /dev/null +++ b/pyquadcortex/device/blocks.py @@ -0,0 +1,256 @@ +"""What sits in a grid cell, as the screen shows it. + +A `Block` is reached through a :class:`~pyquadcortex.device.grid.BlockGrid`, and +which grid you reached it through decides which SCENE its answers are about. +That is the design doc's grid duality, and it follows from how the unit works: +a block is placed once per preset, but its bypass - and, from story #13, its +scene-following parameters - differ per scene. + +So two grids hand back two handles on one cell. Scene-invariant facts (where it +is, which virtual device is in it) come from the same underlying payload and +cannot disagree; scene-varying ones resolve through the grid's own scene. Two +handles on the same cell compare EQUAL, and ``is`` is not the test - see +:meth:`Block.__eq__`. + +Nothing here does coordinate arithmetic. Every number came from +:mod:`pyquadcortex.device.translate`, which is the only place a wire index +becomes a screen one. +""" + +from dataclasses import dataclass + +from pyquadcortex import protocol +from pyquadcortex.device import translate + +#: Which physical input feeds a row, in the unit's own vocabulary. The protocol +#: layer's enum, renamed for the screen: this is a PORT rather than a coordinate, +#: so there is nothing to convert. +InputSource = protocol.Input + +#: Where a row goes: a jack, a send, USB, another row, or the Multi-Out. +OutputDestination = protocol.Output + + +@dataclass(frozen=True) +class VirtualDevice: + """An amp, a cab, a pedal or a capture - what the parameter editor calls the + VIRTUAL DEVICE NAME. + + Named for the screen rather than the wire. The protocol layer calls this a + *model*, and so does its ``ModelCatalog``, but *model* is also this project's + word for the domain model, and the unit's own words are VIRTUAL DEVICE LIST + and VIRTUAL DEVICE NAME (``docs/domain-model.md`` section 5). + """ + + id: int #: the catalogue id the wire carries + name: str #: as shown on the unit, e.g. "Brit 2203" + category: str #: AMP, CAB, DELAY and so on + + +class Block: + """One cell of the grid. + + Built by the grid, never directly. ``grid`` is what it reads through, and it + is the grid that decides which scene the scene-varying answers are about. + """ + + def __init__(self, grid, *, row: int, slot=None): + self._grid = grid + self._row = row + self._slot = slot + + @property + def row(self) -> int: + """Which row this block is on, 1 to 4, as the screen numbers them.""" + return self._row + + @property + def slot(self): + """Which of the row's eight slots this block is in, 1 to 8. + + ``None`` for the input and output blocks, which sit outside the eight - + they are the ends of the row rather than cells in it. + """ + return self._slot + + def __eq__(self, other) -> bool: + """Two handles on the same cell of the same preset are equal. + + **``is`` is deliberately not the test.** ``preset.blocks[1, 3]`` and + ``scene.blocks[1, 3]`` are two BINDINGS of one cell, and the whole point + of a binding is that the scene-varying answers may differ - so they + cannot be one object. What they share is the cell of one preset, which + is what this compares. Within ONE grid the same handle comes back, so + ``grid = preset.blocks`` then ``grid[1, 3] is grid[1, 3]`` holds - + but ``preset.blocks[1, 3] is preset.blocks[1, 3]`` does NOT, because + ``preset.blocks`` builds a fresh grid every time it is read. + """ + if type(other) is not type(self): + return NotImplemented + return self._key() == other._key() + + def __hash__(self) -> int: + return hash(self._key()) + + def _key(self): + """What makes two handles the same cell. + + The PRESET, not the payload. An earlier version keyed on the identity of + the wire payload, which the model replaces on every re-read - so a block + put in a set was silently lost the moment somebody touched the unit, and + `hash()` reached through to the cache and could issue a device read with + a fifteen-second timeout. Neither belongs in an equality check. + """ + return (type(self).__name__, id(self._grid.preset), self._row, self._slot) + + def __repr__(self) -> str: + where = f"row {self._row}" + if self._slot is not None: + where += f" slot {self._slot}" + return f"<{type(self).__name__} {where}>" + + +class DeviceBlock(Block): + """A virtual device placed in one of a row's eight slots.""" + + def __init__(self, grid, *, row: int, slot: int, device_id: int): + super().__init__(grid, row=row, slot=slot) + self._device_id = device_id + + @property + def device(self) -> VirtualDevice: + """Which virtual device is in this cell. + + The catalogue comes FROM the unit, so it covers purchased plugin devices + and the player's own Neural Captures. An id it does not have raises the + catalogue's own ``KeyError``, which already says what is wrong - a + placeholder name here would be the model guessing. + """ + found = self._grid.catalog[self._device_id] + return VirtualDevice(id=found.id, name=found.name, + category=found.category) + + @property + def bypassed(self) -> bool: + """Whether this block is bypassed IN THIS GRID'S SCENE. + + Bypass is scene-varying, so the answer depends on which grid you reached + this block through: ``preset.blocks`` follows whichever scene is active, + and ``scene.blocks`` is pinned to its own. + """ + return translate.block_bypassed(self._grid.wire, self._row, self._slot, + self._grid.scene) + + +class InputBlock(Block): + """The left-hand end of a row: what feeds it.""" + + def __init__(self, grid, *, row: int): + super().__init__(grid, row=row) + + @property + def source(self): + """Which physical input feeds this row, or ``None`` if unstated. + + ``InputSource.EMPTY`` is a real answer meaning "not fed from a physical + jack", which is the normal state of any row that is not an input row - + factory "Brit 2203" has six blocks on a row reporting EMPTY. ``None`` + means the preset did not carry the field at all, which is a different + thing. + + The NOISE REDUCTION / BYPASS / INPUT GAIN controls on this block are + parameters, and parameters are story #13. + """ + return translate.row_input(self._grid.wire, self._row) + + +class LaneOutput: + """The manual's LANE OUTPUT CONTROL: VOLUME, PAN, MUTE and SOLO for a row. + + Present or absent is all this reports today. Its four controls are + parameters and arrive with story #13; what matters here is that a row routed + to another row HAS no lane output, exactly as the screen shows - see + :attr:`OutputBlock.lane`. + """ + + def __init__(self, grid, *, row: int): + self._grid = grid + self._row = row + + @property + def row(self) -> int: + return self._row + + def __repr__(self) -> str: + return f"" + + +class OutputBlock(Block): + """The right-hand end of a row: where it goes.""" + + def __init__(self, grid, *, row: int): + super().__init__(grid, row=row) + + @property + def destination(self): + """Where this row goes, or ``None`` if the preset did not say. + + A jack, a send, USB, the Multi-Out, or another row. WHICH row it feeds + is not reported: the enum names say 3 and 4 and the unit has four rows, + so screen numbering is the obvious reading, but obvious is not confirmed + and a wrong answer there is the silent kind. + """ + return translate.row_output(self._grid.wire, self._row) + + @property + def lane(self): + """This row's LANE OUTPUT CONTROL, or ``None`` when it feeds another row. + + Absent rather than empty, because that is what the screen does: a row + routed into another row has no lane output to show. Mirroring it keeps + the caller from reading a volume that does not exist. + + Raises if the preset did not say where the row goes at all. ``None`` + there would mean "no lane output", which is a positive claim about a row + whose routing the unit never stated. + """ + destination = self.destination + if destination is None: + # Not the same thing as feeding a row, and it must not read as it. + # The preset carried no out_portid at all, so what this row does is + # unknown rather than known-to-have-no-lane. `row_output` is careful + # to keep the two apart and this would have thrown that away. + raise RuntimeError( + f"this preset does not say where row {self._row} goes, so " + f"whether it has a lane output cannot be answered. Read " + f"output.destination to see that the unit said nothing.") + if translate.routes_to_a_row(destination): + return None + return LaneOutput(self._grid, row=self._row) + + +class SplitterBlock(Block): + """Where a row branches into its parallel path. + + Its position is not on the block. The wire carries a splitter with no column + at all; where the branch starts lives on the chain, which is why + ``translate.branches`` is what finds it. + + TYPE, STEREO, BALANCE, LEVEL TO A/B, FREQUENCY and MODE are parameters and + arrive with story #13. So does MUTE, which is worth a note: the manual lists + a MUTE under SPLITTER PARAMETERS and another under MIXER PARAMETERS, and on + the unit they are ONE control - muting the splitter shows the mixer's MUTE + already engaged. + """ + + +class MixerBlock(Block): + """Where a parallel path rejoins its row. + + Absent when the branch never rejoins, which is an ordinary shape: the manual + places the (S) and (M) tokens independently, and factory "Strat Ambience" + (05B) branches without ever recombining. + + LEVEL A/B, PAN A/B, PHASE and MIXER LEVEL are parameters, story #13, as is + the MUTE it shares with the splitter. + """ diff --git a/pyquadcortex/device/device.py b/pyquadcortex/device/device.py index eb122ce..22d9c69 100644 --- a/pyquadcortex/device/device.py +++ b/pyquadcortex/device/device.py @@ -20,6 +20,7 @@ """ from pyquadcortex import protocol +from pyquadcortex.device.preset import Preset from pyquadcortex.device.state import DeviceState @@ -43,6 +44,7 @@ def __init__(self, client, *, _owns_client: bool = False, _state=None): if _state is None: self._state.listen_on(client) self._state.bind(client) + self._preset = None def _check_open(self) -> None: """Refuse to answer through a `Device` the caller has finished with. @@ -102,6 +104,57 @@ def state(self): self._check_open() return self._state + @property + def preset(self) -> Preset: + """The preset on the grid right now. + + Never ``None`` on a connected device: the unit always has one loaded. + + The object is rebuilt when the unit loads a DIFFERENT preset, so this + always hands back the current one - and a `Preset` somebody held across + that recall reports `is_current` False rather than quietly describing the + preset that used to be there (``docs/domain-model.md`` section 12). + + An EDIT does not rebuild it. The preset is still the same preset; only + the model's copy of its contents is behind, and putting that right is + the state layer's job rather than this object's. + + The first access on a connection this `Device` did not open may read the + loaded slot from the unit, which takes about 3 ms. On one opened by + `connect` the handshake's burst has already delivered it. + """ + self._check_open() + # Read through `value` first so a cold cache asks the unit, then take + # the whole entry: `is_current` compares the slot as a whole, and a + # Preset built from an empty one would call itself current forever. + self._state.value("loaded", "position") + loaded = self._state.cached("loaded") + if self._preset is None or self._preset._loaded != loaded: + self._preset = Preset(self, loaded) + return self._preset + + @property + def events(self): + """Subscribe here to hear what the model noticed. + + :class:`~pyquadcortex.device.events.Changed` when a push moves a value + the model holds, and + :class:`~pyquadcortex.device.events.Invalidated` when it stops trusting + part of its copy - which is how a script following the unit closely + learns that the grid moved without waiting for somebody to read a + property:: + + def watch(event): + print(event) + + device.events.subscribe(watch) + + A subscriber runs on a thread the model owns and MAY read from the + device, which is the whole reason that thread exists. + """ + self._check_open() + return self._state.events + @property def firmware(self) -> str: """The firmware version the unit reports, e.g. ``"d14e"``. @@ -152,6 +205,10 @@ def close(self) -> None: rather than quietly staying subscribed to somebody else's. """ self._closed = True + # Dropped rather than left in place: a closed Device holding a Preset + # whose every property raises is a live-looking object with nothing + # behind it. + self._preset = None self._state.close() if self._owns_client: self._client.close() diff --git a/pyquadcortex/device/entries.py b/pyquadcortex/device/entries.py index 2163de6..b242902 100644 --- a/pyquadcortex/device/entries.py +++ b/pyquadcortex/device/entries.py @@ -30,6 +30,8 @@ import dataclasses import typing +from google.protobuf.message import Message + from pyquadcortex import protocol from pyquadcortex.protocol.proto import ProductionAutomation_pb2 as pa @@ -64,10 +66,31 @@ class FieldPlan: default value and "unset" are the same bytes and there is nothing to check - which means each of these needs recorded evidence for what the default MEANS, on the entry that declares it. Empty for most. + invalidates: every message of this type makes the entry untrusted, + whatever it carries, and the next read goes to the unit. For a type + the model does not merge - and for one the per-field check CANNOT + see. ``Grid`` carries its meaning in ``action``, which has no + presence and is skipped globally, so an ``UPDATE`` and a ``DELETE`` + with the same payload look identical to it; ``SceneLabel`` gives + ``index`` and ``label`` no presence either, so renaming scene A to a + blank label sets nothing at all in ``ListFields()``. An entry fed by + either has to decide for itself, and this flag is that decision + written down. It does not widen :data:`SCAFFOLDING`, and it is set + per entry and per message type rather than globally. """ kept: frozenset = frozenset() no_presence: frozenset = frozenset() + invalidates: bool = False + + def voids_the_copy(self) -> bool: + """Whether a message of this type makes the entry untrusted on its own. + + Separate from the per-field check, and deliberately so: this answer does + not depend on what the message carried, because for these types what it + carried cannot be seen. + """ + return self.invalidates @dataclasses.dataclass(frozen=True, eq=False) @@ -92,6 +115,13 @@ class does not carry that yet because nothing needs it. It lands with the name: str read: typing.Callable feeds: typing.Mapping + #: Entries whose copies stop being true when THIS entry's value moves. + #: Applied only on a real change, which is what makes it different from + #: listing the same message type on each of them: the model's own READ of + #: this entry reports the slot that is already loaded, and telling three + #: other entries the unit had changed would be the model reporting its own + #: question as news. + resets: tuple = () def fields(self) -> frozenset: """Every field name this entry holds, across all the types that feed it.""" @@ -101,6 +131,27 @@ def fields(self) -> frozenset: return frozenset(found) +def _held(value): + """A value the cache can still trust after the RX thread has moved on. + + A scalar is copied by value and needs nothing. A SUBMESSAGE does: ``getattr`` + hands back a container living inside the message the RX thread just decoded, + which every other listener was handed too. Storing that reference means the + model reports whatever anyone else does to it afterwards, from a thread the + model does not control - and a preset payload is exactly the kind of thing a + caller pokes at. + + So it is copied once, on the way in. The preset entry is the only thing this + costs anything for, and it costs it on a `RecallPreset` push, which arrives + on a recall or a read rather than continuously. + """ + if isinstance(value, Message): + copy = type(value)() + copy.CopyFrom(value) + return copy + return value + + def fields_applied(message, plan: FieldPlan) -> dict: """The fields of ``message`` this plan keeps, as a mapping. @@ -108,13 +159,15 @@ def fields_applied(message, plan: FieldPlan) -> dict: result leaves everything the push did not mention alone. A presence-free field always appears, because there is no such thing as a message of its type that does not carry it. + + A submessage is copied rather than referenced - see :func:`_held`. """ found = {} for name in plan.kept: if protocol.field_present(message, name): - found[name] = getattr(message, name) + found[name] = _held(getattr(message, name)) for name in plan.no_presence: - found[name] = getattr(message, name) + found[name] = _held(getattr(message, name)) return found @@ -231,20 +284,194 @@ def _read_dirty(client) -> dict: return {"is_dirty": client.preset_dirty()} +#: What a change of loaded slot resets, and why each one is here. +#: +#: ``dirty`` is the one that had to be. A recall clears the unsaved-changes flag +#: on the unit and the unit says NOTHING about it - measured 2026-08-15, no +#: ``PresetDirty`` follows a recall. Without this the model would go on +#: reporting edits the recall discarded. +#: +#: ``scene`` is belt and braces. A recall does push a ``Scene`` carrying the new +#: value, so this mark is usually cleared moments later by the push that answers +#: it. It costs nothing when that arrives and saves a wrong answer if it ever +#: does not. +#: +#: ``preset`` is deliberately absent. A recall pushes eight to thirteen ``Grid`` +#: messages and a whole ``RecallPreset``, either of which puts the preset entry +#: right on its own. +_A_RECALL_RESETS = ("dirty", "scene") + + +#: What a recall really pushes, measured on hardware 2026-08-15 across two host +#: recalls. Within about 120 ms of the request:: +#: +#: Grid x 8-13 the new grid, block by block +#: RecallPreset the whole new preset +#: Scene the new active scene +#: SetlistPosition which slot is loaded now +#: +#: and **no PresetDirty at all**. Each of the three plans below follows from +#: that list rather than from the same guess applied three times, which is what +#: this was before somebody measured it. +#: +#: WHICH preset is loaded, which is a different question from what is in it. +#: The two used to be one entry, with an invented counter standing in for the +#: unit's own answer. They are separate now because the unit reports this +#: directly and ``SetlistPosition{READ}`` really does answer - confirmed on +#: hardware 2026-08-15, in 3 ms, echoing the request id. Section 9's table said +#: so and nobody had checked. +#: +#: ``preset.is_current`` compares this rather than counting events, so it is +#: answering with a fact the unit stated rather than with the model's own +#: bookkeeping. +#: +#: The PRESET entry is deliberately not fed by this type at all. A recall +#: pushes the whole new preset in a ``RecallPreset``, which replaces our copy +#: outright, and eight to thirteen ``Grid`` pushes about 90 ms before this one +#: arrives. Marking the preset here would throw away what the connect burst had +#: just delivered, for a message that says nothing about contents. +_LOADED = FieldPlan(kept=frozenset({"folder_key", "position", "is_factory"})) + + +def _read_loaded(client) -> dict: + """``SetlistPosition{READ}``: 3 ms, measured.""" + return fields_applied(client.loaded_position(), _LOADED) + + +LOADED = StateEntry( + name="loaded", + read=_read_loaded, + feeds={pa.SetlistPositionMessage: _LOADED}, + resets=_A_RECALL_RESETS, +) + + DIRTY = StateEntry( name="dirty", read=_read_dirty, - feeds={pa.PresetDirtyMessage: _PRESET_DIRTY}, + feeds={ + pa.PresetDirtyMessage: _PRESET_DIRTY, + }, +) + + +#: The preset on the grid right now. Read from the LIVE grid rather than from a +#: stored slot: ``RecallPreset{READ}`` answers with what is on the grid including +#: unsaved edits, has no side effects, and leaves the active scene alone - where +#: ``read_preset`` RECALLS a slot, discards unsaved edits, resets the active +#: scene and interrupts the audio every time, including when it recalls the +#: preset already loaded. +#: +#: ``reason`` is kept, and the read below answers for it, because every +#: ``RecallPreset`` carries it. Measured on hardware 2026-08-15: the connect +#: burst's seed push sets ``action``, ``preset`` and ``reason``, and so does the +#: push a recall produces. An entry that did not keep it would therefore be +#: marked for re-reading by the very burst that warmed it, and the first read of +#: ``device.preset`` would pay for a round trip the unit had already made. +#: +#: This was tried the other way first. Keeping a field an entry cannot read back +#: is worse than paying for the read - once marked, it would be gone for good - +#: so the answer was to make it readable, which is what +#: ``QuadCortex.read_current_preset_push`` is for. Nothing reads ``reason`` yet; +#: it gets a property when the Directory story gives it one to hang off. +_RECALL_FOR_PRESET = FieldPlan(kept=frozenset({"preset", "reason"})) + +#: A ``Grid`` push is a sparse, keyed delta into a deeply nested structure. The +#: model does NOT merge it: it notes that the grid moved and re-reads the whole +#: live preset on the next access. One edit on the touchscreen produces about +#: forty of these and costs exactly one re-read, because this is a flag rather +#: than a queue, and the read has no side effects. +#: +#: **What merging would take, if it is ever worth doing.** Each push would have +#: to be applied BY KEY into the stored ``BinaryPreset`` - chain by row, model by +#: column, parameter by index - and, to stay honest, the per-field "did this +#: mention something we do not model" check would have to walk that structure +#: recursively rather than reading ``ListFields()`` at the top level. The prize +#: is that reads stay instant while somebody is editing on the unit. The reason +#: it is not here is that the recursive check is where the whole risk of it sits, +#: and it would have sat next to the objects three other stories are blocked on. +#: A caller who needs the fresh value sooner subscribes to ``device.events`` and +#: reads it themselves, which is what that surface is for. +#: +#: ``action`` is deliberately not consulted, and that IS this entry's own +#: decision about it (see :data:`SCAFFOLDING`): an ``UPDATE`` and a ``DELETE`` +#: mean opposite things, and both of them mean the grid moved, which is all this +#: entry needs to know. +_GRID_MOVED = FieldPlan(invalidates=True) + +#: Scene labels and colours live inside the preset payload, so a change to +#: either makes our copy of it wrong. Neither message can be read by the +#: per-field check: ``index`` and ``label`` have no presence, so renaming scene A +#: to a blank label sets nothing in ``ListFields()`` at all. Colours are not +#: modelled, and that is not a reason to ignore them - ``scene_colors`` is in the +#: payload we hold, and there is no harmless-field category. +_SCENE_TEXT_CHANGED = FieldPlan(invalidates=True) + + +def _read_preset(client) -> dict: + """``RecallPreset{READ}``: the live grid, unsaved edits included. + + One request and one reply, like every entry's read. The reply is the unit's + whole answer, so it REPLACES what we hold rather than merging into it. + + Goes through ``read_current_preset_push`` rather than ``read_current_preset`` + so that ``reason`` comes back with the preset. Same request, same match, same + wire - that method is where ``read_current_preset`` does its work. + """ + return fields_applied(client.read_current_preset_push(), _RECALL_FOR_PRESET) + + +PRESET = StateEntry( + name="preset", + read=_read_preset, + feeds={ + pa.RecallPresetMessage: _RECALL_FOR_PRESET, + pa.GridMessage: _GRID_MOVED, + pa.SceneLabelMessage: _SCENE_TEXT_CHANGED, + pa.SceneColorMessage: _SCENE_TEXT_CHANGED, + }, ) -#: Everything the cache tracks. Section 9's table has more rows than this - the -#: preset on the grid, the active scene, the setlists, recents and favourites, -#: the device-level settings - and each arrives with the surface that reads it -#: (#12 and after). An entry with no reader would be a plan, not a fact, and -#: every push mentioning a field it did not keep would mark it for a read nobody -#: had asked for. -ENTRIES = (IDENTITY, DIRTY) +#: Which scene is active. ``Scene{READ}`` answers with ``selected_scene`` and +#: echoes the request id; confirmed live by switching scenes between reads. +_SCENE = FieldPlan(kept=frozenset({"selected_scene"})) + + +def _read_scene(client) -> dict: + """``Scene{READ}``: the active scene, as a ``protocol.Scene``. + + Goes through ``QuadCortex.active_scene``, which unwraps the reply to the + enum, so this builds the mapping by hand rather than through + :func:`fields_applied` - the same shape :func:`_read_dirty` uses, and for the + same reason: using the published reader keeps the model off the transport. + """ + return {"selected_scene": client.active_scene()} + + +SCENE = StateEntry( + name="scene", + read=_read_scene, + feeds={ + pa.SceneMessage: _SCENE, + }, +) + + +#: Everything the cache tracks. Section 9's table still has more rows than this +#: - the setlists, recents and favourites, and the device-level settings - and +#: each arrives with the surface that reads it. An entry with no reader would be +#: a plan, not a fact, and every push mentioning a field it did not keep would +#: mark it for a read nobody had asked for. +#: +#: The Directory's rows are the ones that need something this class does not +#: have. Every read here is one request and one reply, which is how the read path +#: tells its own answer apart from a push that arrived while it was waiting. A +#: setlist listing is a STREAM - one `File` READ makes the unit enumerate its +#: whole tree, several hundred messages over about fifteen seconds - so those +#: entries land with the change to `StateEntry` that lets a read say how many +#: messages it expects. +ENTRIES = (IDENTITY, DIRTY, PRESET, SCENE, LOADED) ENTRY_BY_NAME = {entry.name: entry for entry in ENTRIES} diff --git a/pyquadcortex/device/errors.py b/pyquadcortex/device/errors.py new file mode 100644 index 0000000..f429ee9 --- /dev/null +++ b/pyquadcortex/device/errors.py @@ -0,0 +1,23 @@ +"""The model's own errors. + +Small on purpose. The model raises the protocol layer's ``DeviceLostError`` +rather than inventing a second name for the device going away, and it prevents +what it can with types instead of exceptions - a row that cannot branch has no +``splitter`` at all, so ``rows[2].create_split()`` is something an editor +rejects (``docs/domain-model.md`` section 8). The factory-versus-user preset +split is the same idea and is NOT built yet: nothing separates the two types +until ``save()`` exists in M2, so the model has one ``Preset``. What is left is +the handful of refusals that mirror something the unit itself cannot do. +""" + + +class InactiveSceneError(RuntimeError): + """A write was attempted through a grid bound to a scene that is not active. + + The unit has no way to write to a scene it is not in: you switch to it + first. Doing that silently would change what comes out of the outputs and + LEAVE it changed, which is far more than the caller asked for - so the model + refuses and names :meth:`~pyquadcortex.device.preset.Scene.activate` as the + step to take. Reading through such a grid is fine + (``docs/domain-model.md`` section 10). + """ diff --git a/pyquadcortex/device/events.py b/pyquadcortex/device/events.py new file mode 100644 index 0000000..2191d64 --- /dev/null +++ b/pyquadcortex/device/events.py @@ -0,0 +1,243 @@ +"""What the model noticed, for a caller who wants to know as it happens. + +The model keeps a copy of what the unit is doing and re-reads whatever it stops +trusting - but it only re-reads when somebody asks for a value. A script +following the unit closely wants to know sooner than that, so this is where it +finds out:: + + def watch(event): + print(event) + + with pyquadcortex.connect() as device: + device.events.subscribe(watch) + +Two events, both about the model's copy rather than about the wire: + +* :class:`Changed` - a push moved a value we hold. +* :class:`Invalidated` - we stopped trusting our copy of something. The next + read of it goes to the unit. A subscriber who wants that to happen now can + simply read it. + +**Why there is a thread in here.** The unit's messages arrive on the transport's +receiving thread, and that thread may not read from the unit: the transport +refuses it outright, because a read there would stall the very loop that has to +collect the reply (ADR-0009). Handing an event straight over on that thread would +therefore make the obvious response - go and re-read it - raise. So the receiving +thread only puts the event in a queue, and a thread this module owns hands it to +subscribers, where reading is allowed and expected. + +The costs are worth stating plainly. An event can lag the unit by however long +the subscribers ahead of it take; subscribers are served one at a time, in the +order they subscribed; and a subscriber that blocks forever holds up every event +behind it. None of that can delay the unit or the receiving thread, which is the +property being bought. +""" + +import dataclasses +import logging +import queue +import threading + +log = logging.getLogger(__name__) + +#: What the delivery thread is called, so a caller reading a stack dump knows +#: whose it is - and so a test can prove delivery is not on the caller's thread. +EVENT_THREAD_NAME = "pyquadcortex-events" + +#: Seconds :meth:`EventStream.close` waits for the delivery thread to finish the +#: event in its hands. A subscriber that never returns is the caller's bug, and +#: hanging their ``close()`` on it would turn their bug into ours. +CLOSE_PATIENCE = 2.0 + + +@dataclasses.dataclass(frozen=True) +class ModelEvent: + """Something the model noticed. Subscribe on ``device.events``.""" + + +@dataclasses.dataclass(frozen=True) +class Changed(ModelEvent): + """A push moved a value the model holds. + + Only when the value really moved. The unit does restate things it has + already said, so reporting every push as a change would make the stream + useless for the thing it is for. (``PresetDirty`` is not the example it + looks like: measured 2026-08-14, the unit sends one when the flag CHANGES + and stays quiet on an edit that leaves it true - see ``docs/protocol.md``, + "`PresetDirty` announces a CHANGE of flag, not an edit". The rule here is + cheap and holds whatever the unit does.) + """ + + part: str #: which part of the model's copy, e.g. ``"preset"`` + fields: tuple #: the field names that moved + + +@dataclasses.dataclass(frozen=True) +class Invalidated(ModelEvent): + """The model stopped trusting its copy of ``part``. + + The next read of it goes to the unit. Fired on the change from trusted to + untrusted only, so one edit on the touchscreen - which produces about forty + ``Grid`` pushes - produces one of these rather than forty. + + **A subscriber that does not read goes quiet.** The entry stays untrusted + until something reads it or the unit answers it in full, and this fires on + the TRANSITION - so three separate edits with no read between them produce + one event, not three. That is the right shape for the intended use, which is + "hear this, go and read it", and the wrong shape for a subscriber that only + logs. If you want to know about every edit rather than about your own copy + going stale, read the value when you hear this; the next edit will announce + itself again. + """ + + part: str + why: str + + +class _Stop: + """The sentinel that ends the delivery loop. + + A class rather than ``None`` so that it can never be confused with an event, + and never with a caller publishing nothing by mistake. + """ + + +_STOP = _Stop() + + +class EventStream: + """Where :class:`Changed` and :class:`Invalidated` reach a caller. + + Reached as ``device.events``. One per `Device`, closed with it. + """ + + def __init__(self, thread_name: str = EVENT_THREAD_NAME): + self._lock = threading.Lock() + self._listeners = [] + self._queue = queue.SimpleQueue() + self._thread = None + self._closed = False + self._thread_name = thread_name + + def subscribe(self, listener): + """Call ``listener(event)`` for everything published from now on. + + Returns a callable that unsubscribes; calling it twice is harmless. + + The listener runs on this stream's own thread, one event at a time, in + the order they were published. It MAY read from the device - that is + what the thread is for. If it raises, the exception is logged and every + other subscriber still gets the event. + + Events published BEFORE the first subscriber are not kept. This is a + stream of what is happening, not a log of what happened. + """ + if not callable(listener): + raise TypeError( + f"a subscriber is called with one event, so it has to be " + f"callable; got {type(listener).__name__}") + with self._lock: + if self._closed: + raise RuntimeError( + "this event stream is closed, so nothing will ever be " + "published on it again - open a new connection with " + "pyquadcortex.connect()") + self._listeners.append(listener) + self._start() + + def unsubscribe(): + with self._lock: + if listener in self._listeners: + self._listeners.remove(listener) + + return unsubscribe + + def publish(self, event) -> None: + """Queue one event. Safe on the receiving thread, and it does not block. + + Does nothing at all when nobody has subscribed, so a script that never + asks for events pays nothing for them. That matters more than it looks: + the unit pushes its tempo on every beat of every connection, so a queue + that filled regardless would grow for the life of the process. + """ + with self._lock: + if self._closed or not self._listeners: + return + self._queue.put(event) + + def close(self) -> None: + """Stop delivering and drop every subscriber. Safe to call twice.""" + with self._lock: + if self._closed: + return + self._closed = True + self._listeners = [] + thread, self._thread = self._thread, None + if thread is not None: + self._queue.put(_STOP) + if thread is threading.current_thread(): + # Closing from inside a subscriber, which is an ordinary thing + # to do on a disconnect - `Device.events` invites a subscriber + # to act on what it hears. Joining here would raise + # `RuntimeError: cannot join current thread`, and that would + # abort `DeviceState.close` before it released the in-flight + # write watches and `Device.close` before it released the USB + # interface, locking out Cortex Control and the next connect. + # The sentinel is already queued, so the loop stops as soon as + # this subscriber returns. + log.debug("events.close_from_subscriber - the delivery thread " + "stops when this event finishes") + return + thread.join(timeout=CLOSE_PATIENCE) + if thread.is_alive(): + log.warning( + "events.close_timed_out - a subscriber has not returned " + "after %.1fs, so the delivery thread is still inside it", + CLOSE_PATIENCE) + + def __len__(self) -> int: + """How many subscribers there are.""" + with self._lock: + return len(self._listeners) + + def __repr__(self) -> str: + with self._lock: + state = "closed" if self._closed else "open" + count = len(self._listeners) + return f"" + + # -- internals ------------------------------------------------------------ + + def _start(self) -> None: + """Start the delivery thread. Called with the lock held. + + A daemon thread, so a caller who forgets to close cannot leave the + interpreter waiting on it at exit. `close` is still what stops it + properly, and `Device.close` calls that. + """ + if self._thread is not None: + return + self._thread = threading.Thread(target=self._deliver, + name=self._thread_name, daemon=True) + self._thread.start() + + def _deliver(self) -> None: + while True: + event = self._queue.get() + if event is _STOP: + return + with self._lock: + listeners = list(self._listeners) + for listener in listeners: + try: + listener(event) + except BaseException: + # BaseException, not Exception, for the reason + # `Transport._notify_listeners` gives about the RX thread: a + # subscriber is arbitrary caller code, and the ways it can + # raise outside Exception are ordinary rather than exotic - + # `pytest.fail()` and `sys.exit()` both do. Letting one + # through would end this thread for good, and the failure a + # caller sees is every other subscriber going quiet with no + # error anywhere. + log.exception("events.subscriber_failed on %r", event) diff --git a/pyquadcortex/device/grid.py b/pyquadcortex/device/grid.py new file mode 100644 index 0000000..72d85cc --- /dev/null +++ b/pyquadcortex/device/grid.py @@ -0,0 +1,312 @@ +"""The Grid: four rows of eight slots, and the two ways to look at them. + +A :class:`BlockGrid` is a BINDING of the grid to a scene. ``preset.blocks`` is +live-bound - it reads through whichever scene is active, like the touchscreen +itself - and ``scene.blocks`` is fixed to its own. Both look at one underlying +payload, so which device is placed where cannot disagree between them; only the +scene-varying answers differ, which is the point. + +Writing through a grid bound to a scene that is NOT active is refused. The unit +has no way to do it - you switch scenes first - and doing that silently would +change what comes out of the outputs and leave it changed +(``docs/domain-model.md`` section 10). Reading through such a grid is fine. + +Nothing here does coordinate arithmetic; :mod:`pyquadcortex.device.translate` +owns all of it. +""" + +import typing + +from pyquadcortex.device import translate +from pyquadcortex.device.blocks import (DeviceBlock, InputBlock, MixerBlock, + OutputBlock, SplitterBlock) +from pyquadcortex.device.errors import InactiveSceneError + + +class BlockGrid: + """The grid, bound to a scene. ``preset.blocks`` or ``scene.blocks``. + + Args: + preset: what this reads through. Anything carrying ``wire`` (the payload + the unit sent), ``catalog`` and ``active_scene``. + scene: the scene to pin to, or ``None`` for live-bound - following + whichever scene is active at the moment of each read. + """ + + def __init__(self, preset, scene=None): + self._preset = preset + # Round-tripped through the boundary so a plain "B" and a SceneLetter + # both land as the same type, and so the refusals live in one place. An + # earlier version took `.name` off the wire enum here, which is what + # `scene_from_wire` is FOR - a conversion sitting outside the boundary, + # and one neither structural scan could see: there is no arithmetic in + # it, and the protocol enum is reached through a call rather than + # through a protocol alias. + self._scene = (None if scene is None + else translate.scene_from_wire(translate.scene_to_wire(scene))) + #: Handles already built, keyed by cell. Dropped whenever the payload + #: underneath changes - see :meth:`_cells`. + self._handles = {} + self._built_from = None + + # -- what a block reads through ------------------------------------------- + + @property + def preset(self): + """The preset this grid is a view of. What block equality is keyed on.""" + return self._preset + + @property + def wire(self): + """The preset payload underneath. Re-read from the cache each time, so a + grid reflects what the unit is doing now rather than when it was made.""" + return self._preset.wire + + @property + def catalog(self): + """The unit's own catalogue of virtual devices.""" + return self._preset.catalog + + @property + def scene(self): + """Which scene this grid's answers are about. + + Resolved on every access rather than stored, which is what makes a + live-bound grid live: storing the letter at construction would pin it + silently the first time somebody built one. + """ + if self._scene is None: + return self._preset.active_scene + return self._scene + + # -- reading --------------------------------------------------------------- + + def _cells(self) -> dict: + """Every occupied cell of the current payload, as handles. + + Rebuilt whenever the payload changes. A handle memoized against an older + payload would go on describing the block that USED to be in that cell, + which is the quiet kind of wrong this library exists to avoid - and the + model re-reads the whole preset after every edit, so it happens often. + """ + wire = self.wire + if self._built_from is not wire: + self._handles = { + (placed.row, placed.slot): DeviceBlock( + self, row=placed.row, slot=placed.slot, + device_id=placed.device_id) + for placed in translate.placed_blocks(wire) + } + self._built_from = wire + return self._handles + + def __getitem__(self, where): + """``blocks[row, slot]``, or ``None`` where the cell is empty.""" + if not isinstance(where, tuple) or len(where) != 2: + raise TypeError( + f"a cell is addressed by row and slot, as blocks[1, 3]; " + f"got {where!r}") + row, slot = where + # Validated through the boundary even though the lookup would simply + # miss: blocks[1, 99] meaning "empty" would read as a fact about the + # preset rather than as a coordinate no screen shows. + translate.row_to_wire(row) + translate.slot_to_wire(slot) + return self._cells().get((row, slot)) + + def __iter__(self) -> typing.Iterator: + """The OCCUPIED cells only. + + Deliberately different from ``row.slots``, which reports all eight + including the empty ones. Iterating a grid answers "what is on this + preset"; iterating a row's slots answers "what is in each of its cells". + """ + return iter(self._cells().values()) + + def __len__(self) -> int: + """How many cells hold something.""" + return len(self._cells()) + + # -- writing --------------------------------------------------------------- + + @property + def writable(self) -> bool: + """Whether a write through this grid would reach the unit. + + False only for a grid pinned to a scene that is not active. A live-bound + grid is always writable, because it follows the active scene and so + cannot be pointed at the wrong one. + """ + return self._scene is None or self._scene == self._preset.active_scene + + def check_writable(self) -> None: + """Raise unless a write through this grid could reach the unit. + + Public because it is the precondition every write through a grid runs, + and this release ships the guard before the writes it guards - editing + is M2. A caller can ask before attempting one, and the refusal names the + step that fixes it. + + Raises: + InactiveSceneError: if this grid is pinned to a scene that is not + active. + """ + if self.writable: + return + raise InactiveSceneError( + f"this grid is bound to scene {self._scene}, which is not the " + f"active one ({self._preset.active_scene}). The unit cannot write " + f"to a scene it is not in, so switch to it first with " + f"scene.activate() - reading through this grid is fine.") + + def __repr__(self) -> str: + binding = "live" if self._scene is None else f"scene {self._scene}" + return f"" + + +class Slots: + """A row's eight cells, as the manual counts them. + + All eight, always, whether or not they hold anything - which is what the + screen shows, and the opposite of iterating a :class:`BlockGrid`. + """ + + def __init__(self, grid: BlockGrid, row: int): + self._grid = grid + self._row = row + + def __getitem__(self, slot: int): + """``row.slots[3]`` - the block in that cell, or ``None``.""" + return self._grid[self._row, slot] + + def __iter__(self) -> typing.Iterator: + return (self._grid[self._row, slot] for slot in translate.SLOTS) + + def __len__(self) -> int: + return len(translate.SLOTS) + + def __repr__(self) -> str: + held = sum(1 for block in self if block is not None) + return f"" + + +class Row: + """One row of the grid, numbered 1 to 4 as the screen numbers them.""" + + def __init__(self, grid: BlockGrid, number: int): + # Not validated here. Every way to reach a Row goes through the + # boundary first - `Rows.__getitem__` checks the number and + # `translate.path_b_of` produces one - so a check here would be a second + # account of what a row is. + self._grid = grid + self._number = number + + @property + def number(self) -> int: + return self._number + + @property + def input(self) -> InputBlock: + """The left-hand end of this row: what feeds it.""" + return InputBlock(self._grid, row=self._number) + + @property + def output(self) -> OutputBlock: + """The right-hand end of this row: where it goes.""" + return OutputBlock(self._grid, row=self._number) + + @property + def slots(self) -> Slots: + """This row's eight cells, empty ones included.""" + return Slots(self._grid, self._number) + + def __repr__(self) -> str: + return f"<{type(self).__name__} {self._number}>" + + +class SplittableRow(Row): + """A row a branch can start on: rows 1 and 3 only. + + A split belongs to a PAIR of rows and only the upper one can start it - the + manual is explicit, "Route audio from Rows 1 or 3 (Path A) to Rows 2 or 4 + (Path B)". So this row IS Path A and :attr:`path_b` IS Path B; no separate + pair object is needed and no row is reachable by two names. + + Because rows 2 and 4 are a plain :class:`Row` with no ``splitter`` at all, + ``rows[2].create_split()`` is something an editor rejects rather than + something that raises when it runs. That catch needs a LITERAL index: a + computed one resolves to ``Row | SplittableRow``, so narrow it or accept a + runtime error. Better than no check, and not absolute. + """ + + def _branch(self): + for branch in translate.branches(self._grid.wire): + if branch.row == self._number: + return branch + return None + + @property + def splitter(self): + """Where this row branches, or ``None`` if it does not.""" + branch = self._branch() + if branch is None: + return None + return SplitterBlock(self._grid, row=self._number, slot=branch.at) + + @property + def mixer(self): + """Where the parallel path rejoins, or ``None`` if it never does. + + A branch need not rejoin: the manual allows Path B to reach different + output blocks instead, and the (S) and (M) tokens are placed + independently. Factory "Strat Ambience" (05B) branches and never + recombines. + """ + branch = self._branch() + if branch is None or branch.rejoins_at is None: + return None + return MixerBlock(self._grid, row=self._number, slot=branch.rejoins_at) + + @property + def path_b(self) -> Row: + """The row carrying this row's parallel path: 2 for row 1, 4 for row 3. + + A plain :class:`Row`, because Path B cannot itself branch. + """ + return Row(self._grid, translate.path_b_of(self._number)) + + +class Rows: + """The grid's four rows. ``preset.rows[1]`` to ``preset.rows[4]``.""" + + def __init__(self, grid: BlockGrid): + self._grid = grid + + @typing.overload + def __getitem__(self, row: typing.Literal[1, 3]) -> SplittableRow: ... + + @typing.overload + def __getitem__(self, row: typing.Literal[2, 4]) -> Row: ... + + def __getitem__(self, row: int) -> Row: + """One row, 1 to 4. + + Rows 1 and 3 come back as :class:`SplittableRow` and rows 2 and 4 as a + plain :class:`Row`, so a type checker can reject ``rows[2].splitter`` + before it runs - on a literal index. See :class:`SplittableRow`. + """ + # Refused through the boundary, so "row 5" is one message wherever it + # is asked. + translate.row_to_wire(row) + if row in translate.SPLITTABLE_ROWS: + return SplittableRow(self._grid, row) + return Row(self._grid, row) + + def __iter__(self) -> typing.Iterator[Row]: + return (self[row] for row in translate.ROWS) + + def __len__(self) -> int: + return len(translate.ROWS) + + def __repr__(self) -> str: + return f"" diff --git a/pyquadcortex/device/preset.py b/pyquadcortex/device/preset.py new file mode 100644 index 0000000..c88832e --- /dev/null +++ b/pyquadcortex/device/preset.py @@ -0,0 +1,297 @@ +"""The preset on the grid, and its eight scenes. + +Everything here reads through the state layer, so a value is what the unit is +doing now rather than what it was doing when the object was built. Reading a +property never changes what comes out of the outputs; the one thing here that +does - :meth:`Scene.activate` - is a method, deliberately, because principle 4 +says nothing audible may be a side effect of a read. +""" + +from pyquadcortex import protocol +from pyquadcortex.device import translate +from pyquadcortex.device.grid import BlockGrid, Rows + + +class Scene: + """One of a preset's eight scenes, as the unit labels them: A to H.""" + + def __init__(self, preset: "Preset", letter: translate.SceneLetter): + self._preset = preset + self._letter = letter + + @property + def letter(self) -> translate.SceneLetter: + return self._letter + + @property + def name(self) -> str: + """This scene's label, as Gig View's EDIT SCENE shows it. + + Empty when the scene has no label. The unit stores a single space for + that rather than an empty string, and shows the letter on screen + instead, so ``if scene.name:`` means what a caller expects. + """ + return translate.scene_name(self._preset.wire, self._letter) + + @property + def blocks(self) -> BlockGrid: + """The grid as THIS scene sees it. + + Fixed-bound: it goes on answering about this scene whatever the unit + switches to. Contrast ``preset.blocks``, which follows the active scene. + """ + return BlockGrid(self._preset, scene=self._letter) + + @property + def is_active(self) -> bool: + """Whether the unit is on this scene right now.""" + return self._preset.active_scene == self._letter + + def activate(self): + """Switch the unit to this scene. + + **Audible.** This is what comes out of the outputs changing, which is + why it is a method rather than something a property does on your behalf + (design principle 4, the same rule that makes recalling explicit). + + The model's copy is updated before the unit's echo arrives, because + waiting for it would make every write pay for information we almost + always already have. A matching echo then changes nothing, which is one + code path rather than two (``docs/domain-model.md`` section 9, rule 3). + If the write never reaches the unit, the active scene is marked for + re-reading and the exception is passed on. + + Returns: + The write's watch. Ignoring it is fine and normal - the outcomes are + logged either way - but a caller who wants to know can wait on it. + """ + # The check `is_current` describes. Without it, a Scene reached through + # a Preset somebody held across a recall would switch the scene of + # whatever is loaded NOW - audibly, and on a preset the caller never + # opened. + self._preset._check_current() + index = translate.scene_to_wire(self._letter) + state = self._preset.state + client = self._preset.client + return state.write_through( + "scene", {"selected_scene": index}, + send=lambda: client.switch_scene(index)) + + def __eq__(self, other): + if not isinstance(other, Scene): + return NotImplemented + return (self._letter, id(self._preset)) == (other._letter, + id(other._preset)) + + def __hash__(self): + return hash((self._letter, id(self._preset))) + + def __repr__(self) -> str: + return f"" + + +class Scenes: + """A preset's eight scenes. ``preset.scenes["B"]``, ``scenes.active``.""" + + def __init__(self, preset: "Preset"): + self._preset = preset + + @property + def active(self) -> Scene: + """The scene the unit is on right now.""" + return Scene(self._preset, self._preset.active_scene) + + def __getitem__(self, letter) -> Scene: + """One scene, by the letter the unit labels it with. + + A bare number is refused. Scene B is wire index 1, so a number here + reads as either one, and the model never takes an index where the + screen shows a letter. + """ + wire = translate.scene_to_wire(letter) + return Scene(self._preset, translate.scene_from_wire(wire)) + + def __iter__(self): + return (Scene(self._preset, letter) for letter in translate.SceneLetter) + + def __len__(self) -> int: + return len(translate.SceneLetter) + + def __repr__(self) -> str: + return f"" + + +class Preset: + """The preset on the grid. + + Reached as ``device.preset``, which always hands back the current one. Hold + one across a recall and it reports :attr:`is_current` False rather than + quietly describing the preset that used to be loaded. + """ + + def __init__(self, device, loaded): + """Internal. Use ``device.preset``. + + Args: + device: the `Device` this reads through. + loaded: which slot was loaded when this was built, as the ``loaded`` + entry reports it. What :attr:`is_current` compares against. + """ + self._device = device + self._loaded = dict(loaded) + + # -- what the grid and the scenes read through ---------------------------- + + @property + def state(self): + """The state layer. Raises once the `Device` is closed.""" + return self._device.state + + @property + def client(self): + """The protocol connection. Raises once the `Device` is closed.""" + return self._device.client + + def _check_current(self) -> None: + """Refuse to answer once this is not the preset on the grid. + + A `Preset` reads live state, so without this it would go on answering + after the unit loaded something else - and answering with the NEW + preset's contents, which is worse than answering with the old ones. That + is the failure the whole layer exists to avoid, and it is the same one + `Device._check_open` refuses for a closed connection. + + :attr:`is_current` and ``__repr__`` deliberately do not call this: asking + whether an object is still good must not raise, and neither must a + debugger. + """ + if self.is_current: + return + raise RuntimeError( + "this Preset is no longer the one on the grid - the unit has " + "loaded another since it was read, so nothing it could report " + "would be about the preset you opened. Ask the device for the " + "current one with device.preset, or check preset.is_current first.") + + @property + def wire(self): + """The preset payload the unit sent, read through the cache. + + Fetched on every access, so an edit somebody made on the touchscreen is + picked up rather than remembered wrongly. The cache answers from its + copy when it has one and asks the unit when it does not. + + **This is the cache's own object, not a copy, and mutating it corrupts + the model.** It is the seam the grid and the blocks read through rather + than something a caller is meant to hold - the way down to the wire is + ``device.client``, which says the same thing about itself. It is not + copied on the way out because the grid memoizes its handles against this + object's identity, and a fresh copy per access would defeat that and + make every block property re-derive the whole grid. + """ + self._check_current() + return self.state.value("preset", "preset") + + @property + def catalog(self): + """The unit's own catalogue of virtual devices. + + Fetched once per connection and cached by the protocol layer; the first + access costs a transfer of about 47 KB. + """ + return self.client.catalog + + @property + def active_scene(self) -> translate.SceneLetter: + """Which scene the unit is on, as a letter. + + Refuses once this is no longer the loaded preset, for the same reason + `has_unsaved_changes` does: the unit's answer would be about a preset + this object is not. + """ + self._check_current() + return translate.scene_from_wire( + self.state.value("scene", "selected_scene")) + + # -- what the preset reports ---------------------------------------------- + + @property + def name(self) -> str: + """The preset's name, as the Directory shows it.""" + wire = self.wire + if not protocol.field_present(wire, "name"): + raise RuntimeError( + "the unit's answer for this preset carried no name, so there " + "is none to report. Nothing was cached for it, so asking again " + "can still succeed.") + return wire.name + + @property + def has_unsaved_changes(self) -> bool: + """Whether the grid holds edits nobody has saved. + + The italic name on screen. Answered from the model's copy, which the + unit keeps current by pushing this in the connect burst and whenever the + flag CHANGES - so it costs no round trip once the connection is warm. + (Not on every edit: an edit to an already-dirty preset sends nothing, + measured 2026-08-14. That is why this is a cached fact rather than + something counted from edit notifications.) + + A recall clears this, and the unit says nothing about it when it does + (measured), so the model re-reads after a recall rather than waiting to + be told. + + Refuses once this is no longer the loaded preset: the flag the unit + holds is about whatever is on the grid now, which by then is something + else. + """ + self._check_current() + return self.state.value("dirty", "is_dirty") + + @property + def is_current(self) -> bool: + """Whether this is still the preset on the grid. + + Hold a `Preset` while somebody taps a different slot on the touchscreen + and this object is about something the unit is no longer showing. Every + other property here checks this first and refuses, and so does + `Scene.activate` - so neither a read nor a write can quietly land on a + preset the caller never opened (``docs/domain-model.md`` section 12). + + This property is the one that does NOT refuse, because asking whether an + object is still good must not raise. + + Costs no round trip: it compares the loaded slot the unit last reported + against the one this object was built at, both from the model's copy. An + EDIT does not make a preset stale - it is the same preset, and only the + model's copy of its contents is behind. + """ + return self.state.cached("loaded") == self._loaded + + @property + def scenes(self) -> Scenes: + """This preset's eight scenes.""" + return Scenes(self) + + @property + def rows(self) -> Rows: + """The grid's four rows, live-bound to the active scene.""" + return Rows(self.blocks) + + @property + def blocks(self) -> BlockGrid: + """The grid, live-bound to whichever scene is active. + + Reads and writes through the active scene, like the touchscreen itself. + Use ``scene.blocks`` to read one particular scene. + """ + return BlockGrid(self) + + def __repr__(self) -> str: + # Never triggers a device read: repr() is called by debuggers and + # logging, and a model that reports itself wrongly is the one thing this + # library cannot do. So it names the slot it was built at rather than + # the preset's name, which would have to be fetched. + where = self._loaded.get("position") + at = "an unknown slot" if where is None else f"slot {where}" + return f"" diff --git a/pyquadcortex/device/state.py b/pyquadcortex/device/state.py index 0bccb47..9747cc6 100644 --- a/pyquadcortex/device/state.py +++ b/pyquadcortex/device/state.py @@ -40,6 +40,7 @@ from pyquadcortex.device import entries from pyquadcortex.device.entries import fields_applied, unkept_fields +from pyquadcortex.device.events import Changed, EventStream, Invalidated from pyquadcortex.device.watch import (WATCH_PATIENCE, WatchOutcome, Watchdog, WriteWatch) @@ -70,7 +71,7 @@ def __init__(self): #: Drawn from a counter rather than written `+= 1`, which is the #: transport's idiom for the same job (`Transport._ids`). It also keeps #: the package's one-translation-boundary check honest: that check - #: refuses every spelling of index arithmetic outside `translate.py` + #: refuses every spelling of index arithmetic outside `translate/` #: precisely because it cannot tell a counter from an off-by-one on a #: row, and a rule with an exemption for "but mine is fine" is not a #: rule. Do not simplify this back. @@ -109,6 +110,7 @@ def __init__(self): self._closed = False self._watches = {entry.name: [] for entry in entries.ENTRIES} self._watchdog = Watchdog(self._gave_up_on, WATCHDOG_THREAD_NAME) + self._events = EventStream() # -- wiring --------------------------------------------------------------- @@ -153,6 +155,9 @@ def close(self) -> None: # Outside the lock: the watchdog takes it to mark an entry, and stopping # it joins its thread. self._watchdog.stop() + # After the watchdog, so a subscriber cannot be handed an event about a + # cache that is already half torn down. + self._events.close() for watch in in_flight: # Released with no outcome, not timed out. Nothing can settle these # now, so anybody waiting has to be let go - and calling them timed @@ -192,27 +197,78 @@ def apply_push(self, message) -> None: def _apply_one(self, entry, plan, message) -> None: applied = fields_applied(message, plan) unkept = unkept_fields(message, plan) + announce = [] with self._lock: if self._closed: return slot = self._slots[entry.name] slot.arrived() was_empty = not slot.fields + # Worked out BEFORE the update, and against the values we held, + # because a caller tracking changes wants the ones that are + # changes. The unit does restate things it has already said - a + # read's own reply is applied through this path too, and so is + # every echo of a write we made. + moved = tuple(sorted(name for name, value in applied.items() + if name not in slot.fields + or slot.fields[name] != value)) + # A CHANGE needs something to have changed from. The first sighting + # of a value is news worth publishing, but it is not the unit having + # done something - and the entries that reset off this one must only + # reset on the unit really loading a different preset. Without the + # distinction, one `device.preset` on a cold cache resets the dirty + # flag and the active scene and publishes two Invalidated events, + # which is the model reporting its own question as news. + changed_from_known = bool(moved) and not was_empty slot.fields.update(applied) if was_empty and slot.fields: log.debug("cache.filled %s from a %s", entry.name, type(message).__name__) if applied: log.debug("push.applied %s %s", entry.name, sorted(applied)) - if unkept: + if moved: + announce.append(Changed(entry.name, moved)) + why = self._why_untrusted(plan, unkept, message) + # The unit telling us everything this entry holds is the same + # thing a read returns, so it replaces rather than merges and + # there is nothing left to ask about. That is what makes the + # connect burst leave the cache warm rather than nominally warm: + # measured, it marks two entries and answers both in full, in that + # order, inside ten milliseconds. + # + # Judged on what the MESSAGE carried, not on what its plan could + # carry. A plan-level version of this check was written first and + # every branch of it turned out to be unreachable - a plan that + # voids the copy never gets here, because `why` is not None for it, + # and a plan keeping nothing cannot match a non-empty field set. + answered = not unkept and set(applied) == entry.fields() + if why is None and slot.needs_read and answered: + slot.needs_read = False + log.debug("cache.answered %s - a %s carried the whole entry", + entry.name, type(message).__name__) + if why is not None: + # Announced on the change from trusted to untrusted only. One + # edit on the touchscreen produces about forty `Grid` pushes, + # and forty identical events would make the stream unusable for + # the thing it exists for. + if not slot.needs_read: + announce.append(Invalidated(entry.name, why)) slot.needs_read = True - log.info("push.forced_reread %s - a %s named %s, which the " - "model does not keep", entry.name, - type(message).__name__, ", ".join(unkept)) + log.info("push.forced_reread %s - %s", entry.name, why) settled = [(watch, watch.absorb(applied)) for watch in self._watches[entry.name]] self._watches[entry.name] = [watch for watch, outcome in settled if outcome is None] + # Outside the lock. A subscriber runs on the event stream's own thread + # rather than this one, but publishing takes that stream's lock, and + # holding two is how a deadlock gets written. + for event in announce: + self._events.publish(event) + if changed_from_known: + for name in entry.resets: + self.mark_for_reread( + name, f"the unit loaded a different preset, and a recall " + f"resets this along with the grid") for watch, outcome in settled: if outcome is None: continue @@ -230,6 +286,38 @@ def _apply_one(self, entry, plan, message) -> None: f"the unit disagreed with a write of {sorted(watch.sent)}") watch.publish() + @staticmethod + def _why_untrusted(plan, unkept, message): + """Why this push means the entry's copy cannot be trusted, or ``None``. + + Two reasons, and the second is not a special case of the first. + + A plan that VOIDS the copy says so whatever the message carried, because + for the types it is set on, what the message carried cannot be seen. + Three types are declared that way today, all of them on the preset + entry: ``Grid`` carries its meaning in ``action``, which has no presence + and is skipped globally, and ``SceneLabel`` and ``SceneColor`` give + ``index`` and their value field no presence either, so a blank rename + sets nothing at all. Asking the per-field check about those would get + "nothing here" every time. + + Otherwise it is the ordinary per-field answer: the push named something + this entry does not keep. + """ + kind = type(message).__name__ + if plan.voids_the_copy(): + # Deliberately says what happened rather than naming a message + # type's usual meaning: this reaches a caller as `Invalidated.why` + # and an operator as a log line, and an earlier version told both + # that a SetlistPosition "changed the preset" while marking the + # dirty flag, which is two wrong things in one sentence. + return (f"a {kind} carries a change the model does not merge, so " + f"its copy is re-read rather than patched") + if unkept: + return (f"a {kind} named {', '.join(unkept)}, which the model does " + f"not keep") + return None + # -- what we hand back (the caller's thread) ------------------------------ def value(self, entry_name: str, field: str): @@ -285,10 +373,21 @@ def value(self, entry_name: str, field: str): # from. An entry whose read provokes a stream will have to say # how many messages that is; see `StateEntry`. extra = slot.witnessed - witnessed_before + retracted = slot.needs_read and extra <= 1 slot.needs_read = extra > 1 if slot.needs_read: log.info("push.forced_reread %s - %d message(s) arrived " "while it was being read", entry_name, extra) + elif retracted: + # The read consumed a mark, which is ADR-0011's rule. Said + # out loud because the mark may have been ANNOUNCED to a + # caller as `Invalidated`, and a subscriber acting on it + # then gets a cached value and no round trip - which reads + # as the event having been wrong. It was not: the read that + # answered it is this one. + log.debug("cache.mark_consumed %s - the read that cleared " + "it is the one a subscriber would have made", + entry_name) if field in slot.fields: return slot.fields[field] raise RuntimeError( @@ -311,6 +410,23 @@ def cached(self, entry_name: str) -> dict: self._check_open() return dict(self._slots[self._entry(entry_name).name].fields) + @property + def events(self): + """Where a caller subscribes to :class:`~pyquadcortex.device.events.Changed` + and :class:`~pyquadcortex.device.events.Invalidated`. + + Not gated on :meth:`_check_open` HERE, because subscribing to a closed + stream raises its own error saying exactly that, and a caller unwinding + after a disconnect should be able to ask how many subscribers are left + without a second exception landing on top of the first. + + ``Device.events`` does gate on the `Device` being open, so a caller + reaching it the ordinary way gets the ordinary "this Device is closed" + message. This property is the way in for anything already holding the + state layer, which is where the unwinding happens. + """ + return self._events + def needs_read(self, entry_name: str) -> bool: """Whether the next :meth:`value` on this entry will go to the unit. diff --git a/pyquadcortex/device/translate.py b/pyquadcortex/device/translate.py deleted file mode 100644 index 670c313..0000000 --- a/pyquadcortex/device/translate.py +++ /dev/null @@ -1,583 +0,0 @@ -"""The one place a screen value becomes a wire value, and back. - -The model speaks what the touchscreen shows: rows 1 to 4, slots 1 to 8, scenes -and footswitches as letters, levels in dB, the tuner in Hz, the tempo in bpm. The -wire speaks zero-based indexes and raw scales. Every conversion between the two -lives here, and nowhere else in :mod:`pyquadcortex.device` - design principle 5 -in ``docs/domain-model.md``. - -**Why one module rather than a convention.** The protocol layer's own header says -it plainly: rows are zero-based, "getting this wrong is quiet rather than loud - -an edit lands on a real row, just not the one intended, and it reads back -perfectly". There is no error, no wrong-looking value, and no complaint from the -unit. A ``- 1`` written in the wrong place is therefore invisible until someone -plays the preset. Collecting the arithmetic in one module makes it reviewable in -one place, and ``tests/test_translation.py`` proves the rest of the model package -contains none of it. - -Nothing here talks to a device. These are pure functions and value types, so they -are cheap to test exhaustively, which is the point. - -Two words carry two meanings in this file, both of them the unit's own: - -* a **slot** is one of the eight cells in a grid row (``row.slots[3]``), and it - is also a preset's place in a setlist ("28C"). The design doc uses both. The - grid sense converts with :func:`slot_to_wire`; the setlist sense with - :func:`slot_to_position` and :class:`PresetAddress`. -* a **position** is the letter part of a preset address ("C") to the model, and - the linear index of that address (218) to the wire. -""" - -import enum -import re -from dataclasses import dataclass - -from pyquadcortex import protocol - -#: Rows on the touchscreen, top to bottom. The wire numbers the same four 0 to 3. -ROWS = (1, 2, 3, 4) - -#: The eight cells in a row, as the manual counts them ("four rows, each -#: containing eight device block slots"). The wire calls a cell a ``column`` and -#: numbers them 0 to 7. -SLOTS = (1, 2, 3, 4, 5, 6, 7, 8) - -#: The tuner's reference pitch when the wire offset is zero. The wire stores an -#: OFFSET from this, not the pitch itself - see :func:`tuner_reference_hz`. -CONCERT_A_HZ = 440.0 - -# What the wire may carry for a row and a slot, derived from the screen values -# above so the two accounts cannot disagree about how many there are. Scene and -# footswitch indexes are NOT validated against these: they have their own enums -# at the protocol layer, and borrowing a range named for grid columns to check a -# footswitch is the confusion this module exists to end. -_WIRE_ROWS = tuple(range(len(ROWS))) -_WIRE_COLUMNS = tuple(range(len(SLOTS))) - -#: Only the three value types are re-exported from :mod:`pyquadcortex`; a caller -#: holds those. The conversions are the seam's own business and are reached as -#: ``translate.row_to_wire(...)`` from inside the model. -__all__ = [ - "ROWS", "SLOTS", "CONCERT_A_HZ", - "FootswitchLetter", "SceneLetter", "PresetAddress", - "row_to_wire", "row_from_wire", "slot_to_wire", "slot_from_wire", - "footswitch_to_wire", "footswitch_from_wire", - "scene_to_wire", "scene_from_wire", - "slot_to_position", "position_to_slot", - "input_level_db", "db_to_input_level", - "lane_level_db", "db_to_lane_level", - "tempo_bpm", "bpm_to_tempo", - "tuner_reference_hz", "hz_to_tuner_reference", - "hold_timing_ms", "ms_to_hold_timing", -] - - -def _a_whole_number(value, what: str) -> int: - """A plain ``int``, and nothing that is merely spelled like one. - - Three things are refused here, and each one is a silent wrong answer rather - than a crash if it gets through: - - * ``bool``, because it subclasses ``int`` and ``True == 1``, so an unguarded - check converts ``True`` to the first row or slot and edits it. - * a ``float``, because ``1.0`` means the caller is computing coordinates in - a type that rounds, and 218.9 becoming preset 218 recalls a real preset. - * any :class:`enum.Enum`, because the protocol layer's coordinate enums are - ``IntEnum``: :class:`~pyquadcortex.protocol.enums.Scene` ``B`` is 1, and - handing it to a row converter otherwise produces row 2 without complaint. - That is the footswitch-versus-column confusion in another costume. The two - wire-index converters that legitimately take one of those enums unwrap it - themselves, so only the RIGHT enum gets through. - """ - if isinstance(value, enum.Enum): - raise TypeError( - f"{what} must be a plain int; {value!r} is a {type(value).__name__}, " - f"which numbers something else" - ) - if isinstance(value, bool) or not isinstance(value, int): - raise TypeError( - f"{what} must be an int, not {type(value).__name__} ({value!r})" - ) - return value - - -def _screen_number(value, what: str, allowed: tuple) -> int: - """One screen coordinate, checked against the values the unit shows.""" - _a_whole_number(value, what) - if value not in allowed: - contiguous = allowed[-1] - allowed[0] + 1 == len(allowed) - span = (f"{allowed[0]} to {allowed[-1]}" if contiguous - else f"one of {list(allowed)}") - raise ValueError(f"{what} must be {span} - the unit has " - f"{len(allowed)} of them; got {value}") - return value - - -def _a_number(value, what: str) -> float: - """A real number, and not a bool wearing one's clothes.""" - if isinstance(value, bool) or not isinstance(value, (int, float)): - raise TypeError( - f"{what} must be a number, not {type(value).__name__} ({value!r})") - return float(value) - - -# -- coordinates: rows and slots -------------------------------------------- - - -def row_to_wire(row: int) -> int: - """The screen's row number (1-4) as the wire's row index (0-3).""" - return _screen_number(row, "a row", ROWS) - 1 - - -def row_from_wire(index: int) -> int: - """The wire's row index (0-3) as the row number the screen shows (1-4).""" - return _screen_number(index, "a wire row index", _WIRE_ROWS) + 1 - - -def slot_to_wire(slot: int) -> int: - """A row's slot number (1-8) as the wire's column index (0-7). - - The manual calls the eight cells in a row slots; the wire calls the same - thing a column. Same cell, two vocabularies, and this is the seam. - """ - return _screen_number(slot, "a slot", SLOTS) - 1 - - -def slot_from_wire(column: int) -> int: - """The wire's column index (0-7) as the slot number the screen shows (1-8).""" - return _screen_number(column, "a wire column index", _WIRE_COLUMNS) + 1 - - -# -- letters: scenes and footswitches --------------------------------------- - - -class FootswitchLetter(enum.StrEnum): - """A footswitch, as the unit labels it: A to H. - - **The model's only public footswitch key.** A footswitch index is not a - column, and the two are equal often enough to look like the same number: - ``stomp_is_momentary`` is keyed by footswitch index, and that stayed hidden - for months because every sample happened to have the two agree - until a - block at column 3 assigned to footswitch E came back keyed 4 - (``docs/domain-model.md`` section 7). Documenting the difference was not - enough, so the model takes a letter and the zero-based index stays inside the - protocol layer, where :class:`~pyquadcortex.protocol.enums.Footswitch` - already lives. - - It is a ``str``, so it prints as the screen shows it and keys an ordinary - mapping:: - - preset.stomps[FootswitchLetter.E] - preset.stomps["E"] # the same key - """ - - A = "A" - B = "B" - C = "C" - D = "D" - E = "E" - F = "F" - G = "G" - H = "H" - - -class SceneLetter(enum.StrEnum): - """A scene, as the unit labels it: A to H. A ``str``, like - :class:`FootswitchLetter`.""" - - A = "A" - B = "B" - C = "C" - D = "D" - E = "E" - F = "F" - G = "G" - H = "H" - - -def _letter(value, kind: type, what: str, trap: str): - """Coerce a caller's letter into `kind`, refusing a number outright. - - A number is refused rather than converted even though the wire is numeric. - ``trap`` names what that number would more likely have been - the mistake the - letter types exist to make impossible. - - The OTHER letter type is refused too. :class:`SceneLetter` and - :class:`FootswitchLetter` are both ``StrEnum`` over A to H, so each is a - plain string as far as any check goes, and scene E reaching a footswitch API - is the same wrong-thing-right-shape mistake as passing the number 4. - """ - if isinstance(value, kind): - return value - if isinstance(value, enum.Enum): - raise TypeError( - f"{what} is a {kind.__name__}; {value!r} is a " - f"{type(value).__name__}, which labels something else" - ) - if isinstance(value, bool) or isinstance(value, (int, float)): - raise TypeError( - f"{what} is a letter A to H, not the number {value!r} - the model " - f"never takes a bare index here, because {trap}" - ) - if not isinstance(value, str): - raise TypeError( - f"{what} is a letter A to H, not {type(value).__name__} ({value!r})") - try: - return kind(value.strip().upper()) - except ValueError: - raise ValueError( - f"{what} is a letter A to H - the unit shows eight; got {value!r}" - ) from None - - -def footswitch_to_wire(footswitch) -> protocol.Footswitch: - """A footswitch letter as the zero-based index the wire carries. - - Takes a :class:`FootswitchLetter` or the plain letter. An ``int`` is refused: - see :class:`FootswitchLetter` for the block-at-column-3 case that makes a - number here a write that silently lands on the wrong switch. - """ - letter = _letter(footswitch, FootswitchLetter, "a footswitch", - "a footswitch index and a block's column are different " - "numbers that are equal often enough to look alike") - return protocol.Footswitch[letter.value] - - -def footswitch_from_wire(index) -> FootswitchLetter: - """The wire's footswitch index (0-7) as the letter the unit labels it with. - - Takes a plain int or a :class:`~pyquadcortex.protocol.enums.Footswitch`. A - :class:`~pyquadcortex.protocol.enums.Scene` is refused even though it is an - ``IntEnum`` over the same eight numbers, because a scene index arriving here - means something upstream mixed up two things the unit keeps apart. - - The letter comes from the protocol enum's own member name rather than a - second copy of the alphabet, so the two layers cannot disagree about which - index is which switch. - """ - if not isinstance(index, protocol.Footswitch): - _a_whole_number(index, "a wire footswitch index") - return FootswitchLetter(protocol.Footswitch(index).name) - - -def scene_to_wire(scene) -> protocol.Scene: - """A scene letter as the zero-based index the wire carries. - - Takes a :class:`SceneLetter` or the plain letter, as ``scenes["B"]`` does. - """ - letter = _letter(scene, SceneLetter, "a scene", - "scene B is wire index 1, and a number here reads as " - "either one") - return protocol.Scene[letter.value] - - -def scene_from_wire(index) -> SceneLetter: - """The wire's scene index (0-7) as the letter the unit labels it with. - - Takes a plain int or a :class:`~pyquadcortex.protocol.enums.Scene`, and - refuses a footswitch index for the reason in :func:`footswitch_from_wire`. - """ - if not isinstance(index, protocol.Scene): - _a_whole_number(index, "a wire scene index") - return SceneLetter(protocol.Scene(index).name) - - -# -- preset addresses: "28C" on screen, a linear position on the wire ------- - -#: What a slot name may look like to the model: ASCII digits, then one letter, -#: with nothing between them. -#: -#: The protocol helper is looser. It checks the bank with ``str.isdigit()``, -#: which is true for every Unicode digit, and ``int()`` reads those too - so -#: ``protocol.slot_to_position("٢٨C")`` returns 218. That is a real position for -#: a name no screen ever shows, which is the shape of mistake this module exists -#: to stop, so the model checks the name before handing it down. Both of the -#: boundary's doors use this one pattern; they disagreed when only -#: :meth:`PresetAddress.parse` had it. -_SLOT_NAME = re.compile(r"\s*([0-9]+)([A-Za-z])\s*") - - -def slot_to_position(name: str) -> int: - """A preset's slot name ("28C") as the linear position the wire carries (218). - - The letters run **A to H, eight to a bank** - the non-hybrid naming, which is - what the unit shows in every mode except one. A PRESET-containing HYBRID mode - halves the bank to four, so the SAME preset is named differently: linear - position 5 reads "1F" normally and "2B" under that hybrid. So a slot name - - and therefore a :class:`PresetAddress` - is only unambiguous alongside the - mode it was read in. The linear position is not: it means one preset whatever - the footswitches are doing, which is why it is what goes on the wire and why - two addresses are best compared as positions. - - How big a setlist is, and the arithmetic, are - :func:`pyquadcortex.protocol.slot_to_position`'s, so the model and the - protocol layer cannot drift apart on what "28C" means. The SHAPE of the name - is checked here first, against :data:`_SLOT_NAME`, because the protocol - helper accepts non-ASCII digits and the model should not. A zero-padded bank - ("01A") is accepted; :func:`position_to_slot` renders unpadded by default, - because that is what the unit displays. - """ - if not isinstance(name, str): - raise TypeError( - f"a slot name is text like '28C', not {type(name).__name__} " - f"({name!r})") - if not _SLOT_NAME.fullmatch(name): - raise ValueError( - f"a slot name is a bank number and a letter A to H, like '28C': " - f"{name!r}") - return protocol.slot_to_position(name) - - -def position_to_slot(position: int, pad: bool = False) -> str: - """The wire's linear position (218) as the slot name the unit shows ("28C"). - - The inverse of :func:`slot_to_position`, and it carries the same caveat: the - name it returns is the non-hybrid one, so it is only unambiguous alongside - the mode the address was read in. - - Unpadded by default ("1A"), which is what the unit displays; ``pad=True`` - gives "01A". - - A whole number only. The protocol helper takes ``int(position)``, so 218.9 - would quietly become 218 and ``True`` would become 1 - and unlike a bad row, - a bad position names a real preset that recalls without complaint. - """ - return protocol.position_to_slot( - _a_whole_number(position, "a wire preset position"), pad=pad) - - -@dataclass(frozen=True) -class PresetAddress: - """Where a preset lives, as the Directory shows it: a bank and a position. - - ``PresetAddress(28, "C")`` renders as ``"28C"`` and - :meth:`parse` reads the same form back. Malformed input is refused here, - when the address is built, rather than later when something writes it: a bad - address that survives parsing turns into a wire position anyway, and the - device recalls whatever preset is at that position without complaint. - - ``position`` is the letter, "A" to "H" - the non-hybrid naming. See - :func:`slot_to_position` for why an address needs the mode beside it to be - unambiguous, and why comparing positions beats comparing names. - """ - - bank: int - position: str - - def __post_init__(self): - _a_whole_number(self.bank, "a bank") - if not isinstance(self.position, str): - raise TypeError( - f"a position is a letter A to H, not " - f"{type(self.position).__name__} ({self.position!r})") - object.__setattr__(self, "position", self.position.strip().upper()) - # Validation is the protocol helper's, so there is one account of how big - # a setlist is and what a slot name may look like. - slot_to_position(f"{self.bank}{self.position}") - - def __str__(self) -> str: - return f"{self.bank}{self.position}" - - def __repr__(self) -> str: - return f"PresetAddress({str(self)!r})" - - @classmethod - def parse(cls, text: str) -> "PresetAddress": - """Read an address a person wrote: ``"28C"``, ``"01a"``, ``" 32H "``. - - Raises ``ValueError`` for anything that is not a bank number followed by - a letter A to H, and ``TypeError`` for anything that is not text. - """ - if not isinstance(text, str): - raise TypeError( - f"a preset address is text like '28C', not " - f"{type(text).__name__} ({text!r})") - # The same pattern :func:`slot_to_position` uses. Python's `\d` spans - # every Unicode digit, and so does `str.isdigit()`, so an unrestricted - # check reads "٢٨C" as bank 28 - see :data:`_SLOT_NAME`. - match = _SLOT_NAME.fullmatch(text) - if not match: - raise ValueError( - f"a preset address is a bank number and a letter A to H, like " - f"'28C': {text!r}") - return cls(int(match.group(1)), match.group(2)) - - @classmethod - def from_wire(cls, position: int) -> "PresetAddress": - """The address at a linear wire position: ``218`` gives ``"28C"``.""" - return cls.parse(position_to_slot(position)) - - def to_wire(self) -> int: - """This address as the linear position the wire carries.""" - return slot_to_position(str(self)) - - -# -- display units ---------------------------------------------------------- -# -# Every mapping below was measured on hardware and is written up at the protocol -# layer. Three of the five - the two level scales and the tempo - have a protocol -# helper that performs the conversion, and this module calls it rather than -# restating the arithmetic: two copies of a measured scale drift, and both copies -# go on returning a plausible number. The other two have no helper to call. The -# tuner has only a documented rule, and hold timing has the protocol layer's -# constant tuple, which is the part worth sharing. Both are pinned in -# tests/test_translation.py against what the protocol WRITE method expects. -# -# What this module adds either way is the type guard, because the protocol -# helpers are arithmetic and will happily multiply a bool. - - -def input_level_db(level: float) -> float: - """An input port's wire level (0..1) as the dB the unit displays. - - Input gain spans -12 to +60 dB. Delegates to - :func:`pyquadcortex.protocol.input_level_db`, which carries the measurement. - - An input port and a lane are both a 0..1 wire value and they are NOT the - same scale - see :func:`lane_level_db`. - - A level outside 0..1 is converted rather than refused, unlike - :func:`hold_timing_ms`, which refuses an index outside its six. The - difference is that an out-of-span level still has a meaning under a linear - scale - it is off the end of the knob - while an index outside its list - names nothing at all. Neither has been seen from a unit. - """ - return protocol.input_level_db(_a_number(level, "an input level")) - - -def db_to_input_level(db: float) -> float: - """Displayed input-gain dB as the wire level an input port takes. - - Refuses anything outside -12..+60 dB rather than clamping, because a clamped - write lands and reads back as a value the caller never asked for. - """ - return protocol.db_to_input_level(_a_number(db, "an input gain in dB")) - - -def lane_level_db(value: float) -> float: - """A lane, mixer or splitter LEVEL wire value (0..1) as displayed dB. - - These span -40 to +12 dB, with 0 dB at :data:`pyquadcortex.protocol.UNITY_LEVEL` - (10/13). Delegates to :func:`pyquadcortex.protocol.lane_level_db`. - - The bottom of the knob is a detent, not a dB value: wire 0.0 reads "Off" on - screen and -39.5 dB (wire 0.01) is the lowest numeric step. This converts the - scale; it does not model the Off position. - """ - return protocol.lane_level_db(_a_number(value, "a lane level")) - - -def db_to_lane_level(db: float) -> float: - """Displayed dB as the wire value a lane, mixer or splitter LEVEL takes. - - Refuses anything outside -40..+12 dB. - - **-40.0 dB is silence, not the bottom of the knob.** It converts to wire - 0.0, which is the Off detent: the lowest NUMERIC step on the unit is -39.5 - dB, and the screen reads "Off" below it. So asking for -40 dB mutes the - lane, and anything between -40.0 and -39.5 is a reading the screen has no - way to show. For silence, write the wire's 0.0 directly and mean it. - """ - return protocol.db_to_lane_level(_a_number(db, "a lane level in dB")) - - -def tempo_bpm(value: float) -> float: - """A ``TEMPO`` wire value (0..1) as the bpm the unit displays. - - Tempo spans 40 to 240 bpm. Delegates to - :func:`pyquadcortex.protocol.tempo_bpm`, which carries the measurement and its - limits: three screen-vs-wire points, with the two endpoints coming from the - fit rather than from a driven extreme. - - A wire value outside 0..1 is REFUSED, where :func:`input_level_db` and - :func:`lane_level_db` convert one. That difference is the protocol helpers' - rather than a rule added at this seam - the tempo one refuses because the bpm - a caller would read back does not exist on the unit - and this wrapper - neither widens it nor narrows it. - """ - return protocol.tempo_bpm(_a_number(value, "a tempo wire value")) - - -def bpm_to_tempo(bpm: float) -> float: - """A displayed tempo in bpm as the wire value ``TEMPO`` takes. - - Refuses anything outside 40..240 bpm rather than clamping, because a clamped - write lands and reads back as a tempo the caller never asked for. - - This pair is a wrapper and not a home. The protocol layer calls - :func:`pyquadcortex.protocol.bpm_to_tempo` itself, inside - :meth:`~pyquadcortex.protocol.QuadCortex.set_tempo_param`, so the helper has - to stay down there: moving it up here would make the protocol layer import - the model, which is the one direction the layering forbids. - """ - return protocol.bpm_to_tempo(_a_number(bpm, "a tempo in bpm")) - - -def tuner_reference_hz(offset: float) -> float: - """The tuner's wire ``frequency`` as the absolute reference pitch on screen. - - The wire stores an OFFSET from 440 Hz, not the pitch: setting FREQ to 442 on - the unit broadcast ``frequency: 1.99999809``. The screen shows 442, so the - model does too. - - **Evidence:** that single observed pair (442 -> 2.0) is the whole of it. It - fixes the zero point and the direction; that the unit is one Hz per unit - rather than something that merely agrees at 2.0 has not been checked against - a second value on screen, and this function says so rather than implying more - (see :meth:`pyquadcortex.protocol.QuadCortex.set_tuner_reference`). No range - is enforced for the same reason: the unit's FREQ limits have not been read, - and a limit invented here would refuse a setting the unit allows. - - Nothing is rounded either, so the wire's 1.99999809 reads back as - 441.99999809 rather than the 442 on the screen. How many digits the unit's - FREQ field shows has not been read off it, and rounding to a precision - nobody has checked would be the same guess in the other direction. - """ - return CONCERT_A_HZ + _a_number(offset, "a tuner reference offset") - - -def hz_to_tuner_reference(hz: float) -> float: - """A reference pitch in Hz as the offset from 440 the wire carries. - - Inverse of :func:`tuner_reference_hz`; see it for the evidence and for why - no range is enforced. - """ - return _a_number(hz, "a tuner reference pitch") - CONCERT_A_HZ - - -def hold_timing_ms(index: int) -> int: - """The wire's ``hold_timing`` index as the milliseconds the screen shows. - - Six settings, 500 to 1000 ms in 100 ms steps. The device accepts and stores - any integer in that field without validating it, so an index outside the six - means something wrote a value no screen can show - reported rather than - rounded to the nearest real setting. - """ - choices = protocol.QuadCortex.HOLD_TIMING_MS - _a_whole_number(index, "a wire hold-timing index") - if not 0 <= index < len(choices): - raise ValueError( - f"hold timing reads {index!r}, which is outside the " - f"{len(choices)} values the unit offers - something wrote an " - f"unvalidated value into it" - ) - return choices[index] - - -def ms_to_hold_timing(milliseconds: int) -> int: - """Milliseconds as the ``hold_timing`` index the wire carries. - - Only the six values the unit offers convert. Anything else is refused rather - than rounded, and that is meant literally: 500.9 ms is not 500 ms, and - ``"500"`` is not a number. The protocol layer's setter takes - ``int(milliseconds)`` and so accepts both, which is the behaviour this - docstring would otherwise be describing wrongly. - """ - choices = protocol.QuadCortex.HOLD_TIMING_MS - _a_whole_number(milliseconds, "hold timing in ms") - if milliseconds not in choices: - raise ValueError( - f"hold timing must be one of {list(choices)} ms, " - f"not {milliseconds!r}" - ) - return choices.index(milliseconds) diff --git a/pyquadcortex/device/translate/__init__.py b/pyquadcortex/device/translate/__init__.py new file mode 100644 index 0000000..5f58288 --- /dev/null +++ b/pyquadcortex/device/translate/__init__.py @@ -0,0 +1,90 @@ +"""The one place a screen value becomes a wire value, and back. + +The model speaks what the touchscreen shows: rows 1 to 4, slots 1 to 8, scenes +and footswitches as letters, levels in dB, the tuner in Hz, the tempo in bpm. The +wire speaks zero-based indexes and raw scales. Every conversion between the two +lives here, and nowhere else in :mod:`pyquadcortex.device` - design principle 5 +in ``docs/domain-model.md``. + +**Why one place rather than a convention.** The protocol layer's own header says +it plainly: rows are zero-based, "getting this wrong is quiet rather than loud - +an edit lands on a real row, just not the one intended, and it reads back +perfectly". There is no error, no wrong-looking value, and no complaint from the +unit. A ``- 1`` written in the wrong place is therefore invisible until someone +plays the preset. Collecting the arithmetic behind one boundary makes it +reviewable in one place, and ``tests/test_translation.py`` proves the rest of the +model package contains none of it. + +**Why a package.** It started as one module and grew past the size where a reader +can hold it. The split is by responsibility - guards, coordinates, letters, +addresses, display units, and reading a whole preset in screen coordinates - and +every public name is re-exported here, so ``translate.row_to_wire(...)`` still +resolves and no caller changed. + +The exemption the structural tests grant is now a DIRECTORY, which is a bigger +hole than a file, so ``tests/test_translation.py`` names this package's modules +explicitly. A module added here has to come through that list with a reason +beside it; putting the arithmetic in an unlisted ``translate/whatever.py`` fails +a test rather than passing quietly. + +Nothing here talks to a device. These are pure functions and value types, so they +are cheap to test exhaustively, which is the point. + +Two words carry two meanings in this package, both of them the unit's own: + +* a **slot** is one of the eight cells in a grid row (``row.slots[3]``), and it + is also a preset's place in a setlist ("28C"). The design doc uses both. The + grid sense converts with :func:`slot_to_wire`; the setlist sense with + :func:`slot_to_position` and :class:`PresetAddress`. +* a **position** is the letter part of a preset address ("C") to the model, and + the linear index of that address (218) to the wire. +""" + +from pyquadcortex.device.translate.addresses import (PresetAddress, + position_to_slot, + slot_to_position) +from pyquadcortex.device.translate.coordinates import (ROWS, SLOTS, + row_from_wire, + row_to_wire, + slot_from_wire, + slot_to_wire) +from pyquadcortex.device.translate.grid import (SPLITTABLE_ROWS, Branch, + PlacedBlock, block_bypassed, + branches, path_b_of, + placed_blocks, routes_to_a_row, + row_input, row_output, + scene_name) +from pyquadcortex.device.translate.letters import (FootswitchLetter, SceneLetter, + footswitch_from_wire, + footswitch_to_wire, + scene_from_wire, + scene_to_wire) +from pyquadcortex.device.translate.units import (CONCERT_A_HZ, bpm_to_tempo, + db_to_input_level, + db_to_lane_level, + hold_timing_ms, + hz_to_tuner_reference, + input_level_db, lane_level_db, + ms_to_hold_timing, tempo_bpm, + tuner_reference_hz) + +#: Only the three value types are re-exported from :mod:`pyquadcortex`; a caller +#: holds those. The conversions are the seam's own business and are reached as +#: ``translate.row_to_wire(...)`` from inside the model. +__all__ = [ + "ROWS", "SLOTS", "CONCERT_A_HZ", + "FootswitchLetter", "SceneLetter", "PresetAddress", + "row_to_wire", "row_from_wire", "slot_to_wire", "slot_from_wire", + "footswitch_to_wire", "footswitch_from_wire", + "scene_to_wire", "scene_from_wire", + "slot_to_position", "position_to_slot", + "input_level_db", "db_to_input_level", + "lane_level_db", "db_to_lane_level", + "tempo_bpm", "bpm_to_tempo", + "tuner_reference_hz", "hz_to_tuner_reference", + "hold_timing_ms", "ms_to_hold_timing", + "PlacedBlock", "Branch", "SPLITTABLE_ROWS", + "placed_blocks", "branches", "path_b_of", + "row_input", "row_output", "routes_to_a_row", + "block_bypassed", "scene_name", +] diff --git a/pyquadcortex/device/translate/addresses.py b/pyquadcortex/device/translate/addresses.py new file mode 100644 index 0000000..f9578ce --- /dev/null +++ b/pyquadcortex/device/translate/addresses.py @@ -0,0 +1,141 @@ +"""Where a preset lives: "28C" on screen, a linear position on the wire. + +Note the two senses of *slot* this package carries, both of them the unit's own. +Here a slot is a preset's place in a setlist; in +:mod:`~pyquadcortex.device.translate.coordinates` it is one of the eight cells in +a grid row. A *position* is the letter part of an address ("C") to the model, and +the linear index of that address (218) to the wire. +""" + +import re +from dataclasses import dataclass + +from pyquadcortex import protocol +from pyquadcortex.device.translate.guards import _a_whole_number + +#: What a slot name may look like to the model: ASCII digits, then one letter, +#: with nothing between them. +#: +#: The protocol helper is looser. It checks the bank with ``str.isdigit()``, +#: which is true for every Unicode digit, and ``int()`` reads those too - so +#: ``protocol.slot_to_position("٢٨C")`` returns 218. That is a real position for +#: a name no screen ever shows, which is the shape of mistake this module exists +#: to stop, so the model checks the name before handing it down. Both of the +#: boundary's doors use this one pattern; they disagreed when only +#: :meth:`PresetAddress.parse` had it. +_SLOT_NAME = re.compile(r"\s*([0-9]+)([A-Za-z])\s*") + + +def slot_to_position(name: str) -> int: + """A preset's slot name ("28C") as the linear position the wire carries (218). + + The letters run **A to H, eight to a bank** - the non-hybrid naming, which is + what the unit shows in every mode except one. A PRESET-containing HYBRID mode + halves the bank to four, so the SAME preset is named differently: linear + position 5 reads "1F" normally and "2B" under that hybrid. So a slot name - + and therefore a :class:`PresetAddress` - is only unambiguous alongside the + mode it was read in. The linear position is not: it means one preset whatever + the footswitches are doing, which is why it is what goes on the wire and why + two addresses are best compared as positions. + + How big a setlist is, and the arithmetic, are + :func:`pyquadcortex.protocol.slot_to_position`'s, so the model and the + protocol layer cannot drift apart on what "28C" means. The SHAPE of the name + is checked here first, against :data:`_SLOT_NAME`, because the protocol + helper accepts non-ASCII digits and the model should not. A zero-padded bank + ("01A") is accepted; :func:`position_to_slot` renders unpadded by default, + because that is what the unit displays. + """ + if not isinstance(name, str): + raise TypeError( + f"a slot name is text like '28C', not {type(name).__name__} " + f"({name!r})") + if not _SLOT_NAME.fullmatch(name): + raise ValueError( + f"a slot name is a bank number and a letter A to H, like '28C': " + f"{name!r}") + return protocol.slot_to_position(name) + + +def position_to_slot(position: int, pad: bool = False) -> str: + """The wire's linear position (218) as the slot name the unit shows ("28C"). + + The inverse of :func:`slot_to_position`, and it carries the same caveat: the + name it returns is the non-hybrid one, so it is only unambiguous alongside + the mode the address was read in. + + Unpadded by default ("1A"), which is what the unit displays; ``pad=True`` + gives "01A". + + A whole number only. The protocol helper takes ``int(position)``, so 218.9 + would quietly become 218 and ``True`` would become 1 - and unlike a bad row, + a bad position names a real preset that recalls without complaint. + """ + return protocol.position_to_slot( + _a_whole_number(position, "a wire preset position"), pad=pad) + + +@dataclass(frozen=True) +class PresetAddress: + """Where a preset lives, as the Directory shows it: a bank and a position. + + ``PresetAddress(28, "C")`` renders as ``"28C"`` and + :meth:`parse` reads the same form back. Malformed input is refused here, + when the address is built, rather than later when something writes it: a bad + address that survives parsing turns into a wire position anyway, and the + device recalls whatever preset is at that position without complaint. + + ``position`` is the letter, "A" to "H" - the non-hybrid naming. See + :func:`slot_to_position` for why an address needs the mode beside it to be + unambiguous, and why comparing positions beats comparing names. + """ + + bank: int + position: str + + def __post_init__(self): + _a_whole_number(self.bank, "a bank") + if not isinstance(self.position, str): + raise TypeError( + f"a position is a letter A to H, not " + f"{type(self.position).__name__} ({self.position!r})") + object.__setattr__(self, "position", self.position.strip().upper()) + # Validation is the protocol helper's, so there is one account of how big + # a setlist is and what a slot name may look like. + slot_to_position(f"{self.bank}{self.position}") + + def __str__(self) -> str: + return f"{self.bank}{self.position}" + + def __repr__(self) -> str: + return f"PresetAddress({str(self)!r})" + + @classmethod + def parse(cls, text: str) -> "PresetAddress": + """Read an address a person wrote: ``"28C"``, ``"01a"``, ``" 32H "``. + + Raises ``ValueError`` for anything that is not a bank number followed by + a letter A to H, and ``TypeError`` for anything that is not text. + """ + if not isinstance(text, str): + raise TypeError( + f"a preset address is text like '28C', not " + f"{type(text).__name__} ({text!r})") + # The same pattern :func:`slot_to_position` uses. Python's `\d` spans + # every Unicode digit, and so does `str.isdigit()`, so an unrestricted + # check reads "٢٨C" as bank 28 - see :data:`_SLOT_NAME`. + match = _SLOT_NAME.fullmatch(text) + if not match: + raise ValueError( + f"a preset address is a bank number and a letter A to H, like " + f"'28C': {text!r}") + return cls(int(match.group(1)), match.group(2)) + + @classmethod + def from_wire(cls, position: int) -> "PresetAddress": + """The address at a linear wire position: ``218`` gives ``"28C"``.""" + return cls.parse(position_to_slot(position)) + + def to_wire(self) -> int: + """This address as the linear position the wire carries.""" + return slot_to_position(str(self)) diff --git a/pyquadcortex/device/translate/coordinates.py b/pyquadcortex/device/translate/coordinates.py new file mode 100644 index 0000000..0ff37e7 --- /dev/null +++ b/pyquadcortex/device/translate/coordinates.py @@ -0,0 +1,49 @@ +"""Rows and slots: what the screen numbers 1 to 4 and 1 to 8, and the wire 0 up. + +The four converters here are the ones ``tests/test_translation.py`` names by +name. If they stop doing arithmetic, the "nowhere else does this" check below +them starts passing because nothing anywhere converts, so the test asserts they +still do. +""" + +from pyquadcortex.device.translate.guards import _screen_number + +#: Rows on the touchscreen, top to bottom. The wire numbers the same four 0 to 3. +ROWS = (1, 2, 3, 4) + +#: The eight cells in a row, as the manual counts them ("four rows, each +#: containing eight device block slots"). The wire calls a cell a ``column`` and +#: numbers them 0 to 7. +SLOTS = (1, 2, 3, 4, 5, 6, 7, 8) + +# What the wire may carry for a row and a slot, derived from the screen values +# above so the two accounts cannot disagree about how many there are. Scene and +# footswitch indexes are NOT validated against these: they have their own enums +# at the protocol layer, and borrowing a range named for grid columns to check a +# footswitch is the confusion this module exists to end. +_WIRE_ROWS = tuple(range(len(ROWS))) +_WIRE_COLUMNS = tuple(range(len(SLOTS))) + + +def row_to_wire(row: int) -> int: + """The screen's row number (1-4) as the wire's row index (0-3).""" + return _screen_number(row, "a row", ROWS) - 1 + + +def row_from_wire(index: int) -> int: + """The wire's row index (0-3) as the row number the screen shows (1-4).""" + return _screen_number(index, "a wire row index", _WIRE_ROWS) + 1 + + +def slot_to_wire(slot: int) -> int: + """A row's slot number (1-8) as the wire's column index (0-7). + + The manual calls the eight cells in a row slots; the wire calls the same + thing a column. Same cell, two vocabularies, and this is the seam. + """ + return _screen_number(slot, "a slot", SLOTS) - 1 + + +def slot_from_wire(column: int) -> int: + """The wire's column index (0-7) as the slot number the screen shows (1-8).""" + return _screen_number(column, "a wire column index", _WIRE_COLUMNS) + 1 diff --git a/pyquadcortex/device/translate/grid.py b/pyquadcortex/device/translate/grid.py new file mode 100644 index 0000000..1983649 --- /dev/null +++ b/pyquadcortex/device/translate/grid.py @@ -0,0 +1,210 @@ +"""A preset the wire sent, read in the numbers the screen shows. + +The protocol layer reports a grid in its own coordinates: rows 0 to 3, columns 0 +to 7, scenes 0 to 7, and a branch whose columns live on the chain rather than on +the splitter block. Everything here turns one of those into what the touchscreen +shows, which is why it sits inside the translation boundary rather than beside +the model objects that use it. + +``tests/test_translation.py`` refuses every other module in the package the +protocol-layer readers used here - ``blocks``, ``splits`` and ``bypass_state`` +are all on its allowlist - so this is the only place a wire coordinate becomes a +screen one. + +Nothing here decides anything. It reads what the unit sent and renumbers it; the +model objects in :mod:`pyquadcortex.device.grid` decide what to do with that. +""" + +from dataclasses import dataclass + +from pyquadcortex import protocol +from pyquadcortex.device.translate.coordinates import (row_from_wire, + row_to_wire, + slot_from_wire, + slot_to_wire) +from pyquadcortex.device.translate.letters import scene_to_wire + + +@dataclass(frozen=True) +class PlacedBlock: + """One occupied cell: where it is on screen, and what is in it.""" + + row: int #: 1 to 4, as the screen numbers them + slot: int #: 1 to 8, the manual's word for a cell in a row + device_id: int #: the catalogue id of the virtual device placed here + + +@dataclass(frozen=True) +class Branch: + """Where a row splits into a parallel path, in screen numbers. + + :attr:`rejoins_at` is ``None`` for a branch that never recombines. The + manual places the (S) and (M) tokens independently, so that is an ordinary + shape rather than a broken one - factory "Strat Ambience" (05B) branches and + never rejoins. + """ + + row: int #: the row the branch starts on, 1 or 3 + at: int #: the slot holding the splitter, 1 to 8 + rejoins_at: int | None #: the slot holding the mixer, or None + path_b: int #: the row carrying path B, 2 or 4 + + +#: The rows a branch can start on, as the screen numbers them. The wire allows a +#: branch only on its rows 0 and 2 - see :func:`pyquadcortex.protocol.splits` - +#: and those two are these two. Derived rather than written as ``(1, 3)`` so the +#: numbering has exactly one account of itself. +SPLITTABLE_ROWS = (row_from_wire(0), row_from_wire(2)) + + +def path_b_of(row: int) -> int: + """The row carrying ``row``'s parallel path: 2 for row 1, 4 for row 3. + + Refuses rows 2 and 4, because a branch cannot start there and a row that + cannot branch has no path B. The model expresses the same rule as a type - + ``rows[2]`` is a plain ``Row`` with no ``path_b`` at all - and this is the + runtime half, for a row number that was computed rather than written. + """ + if row not in SPLITTABLE_ROWS: + raise ValueError( + f"only rows 1 or 3 start a branch, so only they have a path B; " + f"got {row!r}") + return row_from_wire(row_to_wire(row) + 1) + + +def placed_blocks(binary_preset) -> tuple: + """Every occupied cell, screen-numbered. + + Which cells are occupied is :func:`pyquadcortex.protocol.blocks`' question, + and it is not the naive one: every row reports all eight slots whether or + not they hold anything, so ``len(chain.models)`` is 8 on an empty row. This + adds the numbering and nothing else. + """ + return tuple( + PlacedBlock(row=row_from_wire(block.row), + slot=slot_from_wire(block.column), + device_id=block.model_id) + for block in protocol.blocks(binary_preset)) + + +def branches(binary_preset) -> tuple: + """Every parallel path in this preset, screen-numbered. + + The columns come from the chain's ``split_control_points`` rather than from + the splitter block, which carries no column at all - + :func:`pyquadcortex.protocol.splits` is where that is worked out. + """ + return tuple( + Branch(row=row_from_wire(split.row), + at=slot_from_wire(split.split_column), + rejoins_at=(slot_from_wire(split.mix_column) + if split.rejoins else None), + path_b=row_from_wire(split.lane_row)) + for split in protocol.splits(binary_preset)) + + +def _chain(binary_preset, row: int): + """The chain for a screen row. + + Matched on the chain's own ``row`` where it carries one and by position + otherwise, which is :func:`pyquadcortex.protocol.blocks`' rule. Following a + different rule here would let the two accounts disagree about which row a + chain is, and the fixture read off a real unit carries no ``row`` at all, so + the fallback is the normal path rather than the exceptional one. + """ + wanted = row_to_wire(row) + for position, chain in enumerate(binary_preset.chains): + here = chain.row if protocol.field_present(chain, "row") else position + if here == wanted: + return chain + raise LookupError( + f"this preset carries no chain for row {row}; it has " + f"{len(binary_preset.chains)}") + + +def row_input(binary_preset, row: int): + """The port feeding a screen row, or ``None`` if the preset does not say. + + ``Input.EMPTY`` is a real answer and means "not fed from a physical jack", + which is the normal state of any row that is not an input row - factory + "Brit 2203" has six blocks on a row reporting EMPTY. ``None`` means the + field was absent, which is a different thing and is why the two are not + collapsed. + """ + chain = _chain(binary_preset, row) + if not protocol.field_present(chain, "in_portid"): + return None + return protocol.Input(chain.in_portid) + + +def row_output(binary_preset, row: int): + """Where a screen row goes, or ``None`` if the preset does not say.""" + chain = _chain(binary_preset, row) + if not protocol.field_present(chain, "out_portid"): + return None + return protocol.Output(chain.out_portid) + + +#: The three destinations that feed another row instead of a jack. On screen a +#: row routed this way has no LANE OUTPUT CONTROL, which is what the model +#: mirrors by leaving ``lane`` absent. +_ROW_DESTINATIONS = frozenset({ + protocol.Output.NEXT_ROW_3, + protocol.Output.NEXT_ROW_4, + protocol.Output.NEXT_ROW_3_4, +}) + + +def routes_to_a_row(destination) -> bool: + """Whether this destination feeds another row rather than a jack. + + WHICH row it feeds is deliberately not reported. The names say 3 and 4, the + unit has four rows, and the fixture read off a real unit routes its top row + to ``NEXT_ROW_3`` - so screen numbering is the obvious reading. Obvious is + not confirmed, and a wrong answer here is the silent kind that reads back + perfectly. So the model answers the question it can, which is the one that + decides whether there is a lane output to show. + """ + return destination in _ROW_DESTINATIONS + + +def block_bypassed(binary_preset, row: int, slot: int, scene) -> bool: + """Whether the block in a cell is bypassed in ``scene``. + + The wire keys the eight bypass slots by scene INDEX; this takes the letter, + and it is the only place that mapping happens. + + One lookup covers both cases. With scene mode off the block has a single + bypass state and the unit keeps all eight slots consistent - a global write + updates every one of them, measured - so reading the asked-for scene is + right either way and needs no special case. Reading slot zero instead would + be right for the same reason and wrong the moment scene mode is on, which is + why it is not written that way. + """ + state = protocol.bypass_state(binary_preset, row_to_wire(row), + slot_to_wire(slot)) + index = scene_to_wire(scene) + if index >= len(state.scenes): + raise LookupError( + f"the preset stores {len(state.scenes)} bypass slots for row {row} " + f"slot {slot}, so scene {scene} has none - the unit stores eight") + return state.scenes[index] + + +def scene_name(binary_preset, scene) -> str: + """A scene's label, by letter, as the unit would show it. + + The unit stores a single space for "this scene has no label" rather than an + empty string (:data:`pyquadcortex.protocol.SCENE_UNLABELLED`), and shows the + letter on screen instead of a name. So a blank label reads back here as no + name at all, which is what a caller writing ``if scene.name:`` means and + what ``label == ""`` would get wrong. + """ + index = scene_to_wire(scene) + labels = binary_preset.scene_labels + if index >= len(labels): + raise LookupError( + f"this preset carries {len(labels)} scene labels, so scene {scene} " + f"has none - the unit stores eight") + label = labels[index] + return "" if not label.strip() else label diff --git a/pyquadcortex/device/translate/guards.py b/pyquadcortex/device/translate/guards.py new file mode 100644 index 0000000..8ae0d07 --- /dev/null +++ b/pyquadcortex/device/translate/guards.py @@ -0,0 +1,56 @@ +"""Type checks the conversions share. + +Each one refuses something that is a silent wrong answer rather than a crash if +it gets through, which is why they are checks rather than casts. +""" + +import enum + + +def _a_whole_number(value, what: str) -> int: + """A plain ``int``, and nothing that is merely spelled like one. + + Three things are refused here, and each one is a silent wrong answer rather + than a crash if it gets through: + + * ``bool``, because it subclasses ``int`` and ``True == 1``, so an unguarded + check converts ``True`` to the first row or slot and edits it. + * a ``float``, because ``1.0`` means the caller is computing coordinates in + a type that rounds, and 218.9 becoming preset 218 recalls a real preset. + * any :class:`enum.Enum`, because the protocol layer's coordinate enums are + ``IntEnum``: :class:`~pyquadcortex.protocol.enums.Scene` ``B`` is 1, and + handing it to a row converter otherwise produces row 2 without complaint. + That is the footswitch-versus-column confusion in another costume. The two + wire-index converters that legitimately take one of those enums unwrap it + themselves, so only the RIGHT enum gets through. + """ + if isinstance(value, enum.Enum): + raise TypeError( + f"{what} must be a plain int; {value!r} is a {type(value).__name__}, " + f"which numbers something else" + ) + if isinstance(value, bool) or not isinstance(value, int): + raise TypeError( + f"{what} must be an int, not {type(value).__name__} ({value!r})" + ) + return value + + +def _screen_number(value, what: str, allowed: tuple) -> int: + """One screen coordinate, checked against the values the unit shows.""" + _a_whole_number(value, what) + if value not in allowed: + contiguous = allowed[-1] - allowed[0] + 1 == len(allowed) + span = (f"{allowed[0]} to {allowed[-1]}" if contiguous + else f"one of {list(allowed)}") + raise ValueError(f"{what} must be {span} - the unit has " + f"{len(allowed)} of them; got {value}") + return value + + +def _a_number(value, what: str) -> float: + """A real number, and not a bool wearing one's clothes.""" + if isinstance(value, bool) or not isinstance(value, (int, float)): + raise TypeError( + f"{what} must be a number, not {type(value).__name__} ({value!r})") + return float(value) diff --git a/pyquadcortex/device/translate/letters.py b/pyquadcortex/device/translate/letters.py new file mode 100644 index 0000000..27ecaac --- /dev/null +++ b/pyquadcortex/device/translate/letters.py @@ -0,0 +1,174 @@ +"""Scenes and footswitches: letters A to H on screen, indexes 0 to 7 on the wire. + +The two are separate types on purpose. Both label eight things A to H, both are +strings, and they are equal often enough to look like one idea - which is exactly +why a scene letter reaching a footswitch API has to be a type error. +""" + +import enum + +from pyquadcortex import protocol +from pyquadcortex.device.translate.guards import _a_whole_number + + +class _UnitLetter(enum.StrEnum): + """A letter A to H that labels one KIND of thing on the unit. + + Two subclasses, and they must not be interchangeable. Both are `StrEnum` + over the same eight letters, so without this every check that could tell + them apart - `==`, `in`, a dict lookup - says they are the same. Scene E + reaching a footswitch API is the mistake these types exist to prevent, and + the converters refusing it is not enough when `preset.stomps` is documented + as an ordinary mapping a caller keys by letter. + + Comparison with a plain `str` is kept, because that is the point of the + types being strings: `scenes["B"]`, `str(letter)` and printing all work. + """ + + def __eq__(self, other): + if isinstance(other, _UnitLetter) and type(other) is not type(self): + return False + return str.__eq__(self, other) + + def __ne__(self, other): + result = self.__eq__(other) + return result if result is NotImplemented else not result + + #: Hashed as the plain letter, deliberately. Defining `__eq__` would + #: otherwise make these unhashable, and hashing by type would break the + #: thing the string-ness is FOR - `preset.stomps["E"]`, which the class + #: docstring below advertises. A dict lookup needs the hash to match AND the + #: keys to compare equal, so a mapping keyed by `FootswitchLetter.E` still + #: answers to `"E"` and still raises `KeyError` for `SceneLetter.E`. + __hash__ = str.__hash__ + + +class FootswitchLetter(_UnitLetter): + """A footswitch, as the unit labels it: A to H. + + **The model's only public footswitch key.** A footswitch index is not a + column, and the two are equal often enough to look like the same number: + ``stomp_is_momentary`` is keyed by footswitch index, and that stayed hidden + for months because every sample happened to have the two agree - until a + block at column 3 assigned to footswitch E came back keyed 4 + (``docs/domain-model.md`` section 7). Documenting the difference was not + enough, so the model takes a letter and the zero-based index stays inside the + protocol layer, where :class:`~pyquadcortex.protocol.enums.Footswitch` + already lives. + + It is a ``str``, so it prints as the screen shows it and keys an ordinary + mapping:: + + preset.stomps[FootswitchLetter.E] + preset.stomps["E"] # the same key + """ + + A = "A" + B = "B" + C = "C" + D = "D" + E = "E" + F = "F" + G = "G" + H = "H" + + +class SceneLetter(_UnitLetter): + """A scene, as the unit labels it: A to H. A ``str``, like + :class:`FootswitchLetter` - and deliberately NOT equal to one.""" + + A = "A" + B = "B" + C = "C" + D = "D" + E = "E" + F = "F" + G = "G" + H = "H" + + +def _letter(value, kind: type, what: str, trap: str): + """Coerce a caller's letter into `kind`, refusing a number outright. + + A number is refused rather than converted even though the wire is numeric. + ``trap`` names what that number would more likely have been - the mistake the + letter types exist to make impossible. + + The OTHER letter type is refused too. :class:`SceneLetter` and + :class:`FootswitchLetter` are both ``StrEnum`` over A to H, so each is a + plain string as far as any check goes, and scene E reaching a footswitch API + is the same wrong-thing-right-shape mistake as passing the number 4. + """ + if isinstance(value, kind): + return value + if isinstance(value, enum.Enum): + raise TypeError( + f"{what} is a {kind.__name__}; {value!r} is a " + f"{type(value).__name__}, which labels something else" + ) + if isinstance(value, bool) or isinstance(value, (int, float)): + raise TypeError( + f"{what} is a letter A to H, not the number {value!r} - the model " + f"never takes a bare index here, because {trap}" + ) + if not isinstance(value, str): + raise TypeError( + f"{what} is a letter A to H, not {type(value).__name__} ({value!r})") + try: + return kind(value.strip().upper()) + except ValueError: + raise ValueError( + f"{what} is a letter A to H - the unit shows eight; got {value!r}" + ) from None + + +def footswitch_to_wire(footswitch) -> protocol.Footswitch: + """A footswitch letter as the zero-based index the wire carries. + + Takes a :class:`FootswitchLetter` or the plain letter. An ``int`` is refused: + see :class:`FootswitchLetter` for the block-at-column-3 case that makes a + number here a write that silently lands on the wrong switch. + """ + letter = _letter(footswitch, FootswitchLetter, "a footswitch", + "a footswitch index and a block's column are different " + "numbers that are equal often enough to look alike") + return protocol.Footswitch[letter.value] + + +def footswitch_from_wire(index) -> FootswitchLetter: + """The wire's footswitch index (0-7) as the letter the unit labels it with. + + Takes a plain int or a :class:`~pyquadcortex.protocol.enums.Footswitch`. A + :class:`~pyquadcortex.protocol.enums.Scene` is refused even though it is an + ``IntEnum`` over the same eight numbers, because a scene index arriving here + means something upstream mixed up two things the unit keeps apart. + + The letter comes from the protocol enum's own member name rather than a + second copy of the alphabet, so the two layers cannot disagree about which + index is which switch. + """ + if not isinstance(index, protocol.Footswitch): + _a_whole_number(index, "a wire footswitch index") + return FootswitchLetter(protocol.Footswitch(index).name) + + +def scene_to_wire(scene) -> protocol.Scene: + """A scene letter as the zero-based index the wire carries. + + Takes a :class:`SceneLetter` or the plain letter, as ``scenes["B"]`` does. + """ + letter = _letter(scene, SceneLetter, "a scene", + "scene B is wire index 1, and a number here reads as " + "either one") + return protocol.Scene[letter.value] + + +def scene_from_wire(index) -> SceneLetter: + """The wire's scene index (0-7) as the letter the unit labels it with. + + Takes a plain int or a :class:`~pyquadcortex.protocol.enums.Scene`, and + refuses a footswitch index for the reason in :func:`footswitch_from_wire`. + """ + if not isinstance(index, protocol.Scene): + _a_whole_number(index, "a wire scene index") + return SceneLetter(protocol.Scene(index).name) diff --git a/pyquadcortex/device/translate/units.py b/pyquadcortex/device/translate/units.py new file mode 100644 index 0000000..5194068 --- /dev/null +++ b/pyquadcortex/device/translate/units.py @@ -0,0 +1,177 @@ +"""Display units: dB, Hz, bpm and milliseconds on screen, raw scales on the wire. + +Every mapping below was measured on hardware and is written up at the protocol +layer. Three of the five - the two level scales and the tempo - have a protocol +helper that performs the conversion, and this module calls it rather than +restating the arithmetic: two copies of a measured scale drift, and both copies +go on returning a plausible number. The other two have no helper to call. The +tuner has only a documented rule, and hold timing has the protocol layer's +constant tuple, which is the part worth sharing. Both are pinned in +tests/test_translation.py against what the protocol WRITE method expects. + +What this module adds either way is the type guard, because the protocol helpers +are arithmetic and will happily multiply a bool. +""" + +from pyquadcortex import protocol +from pyquadcortex.device.translate.guards import _a_number, _a_whole_number + +#: The tuner's reference pitch when the wire offset is zero. The wire stores an +#: OFFSET from this, not the pitch itself - see :func:`tuner_reference_hz`. +CONCERT_A_HZ = 440.0 + + +def input_level_db(level: float) -> float: + """An input port's wire level (0..1) as the dB the unit displays. + + Input gain spans -12 to +60 dB. Delegates to + :func:`pyquadcortex.protocol.input_level_db`, which carries the measurement. + + An input port and a lane are both a 0..1 wire value and they are NOT the + same scale - see :func:`lane_level_db`. + + A level outside 0..1 is converted rather than refused, unlike + :func:`hold_timing_ms`, which refuses an index outside its six. The + difference is that an out-of-span level still has a meaning under a linear + scale - it is off the end of the knob - while an index outside its list + names nothing at all. Neither has been seen from a unit. + """ + return protocol.input_level_db(_a_number(level, "an input level")) + + +def db_to_input_level(db: float) -> float: + """Displayed input-gain dB as the wire level an input port takes. + + Refuses anything outside -12..+60 dB rather than clamping, because a clamped + write lands and reads back as a value the caller never asked for. + """ + return protocol.db_to_input_level(_a_number(db, "an input gain in dB")) + + +def lane_level_db(value: float) -> float: + """A lane, mixer or splitter LEVEL wire value (0..1) as displayed dB. + + These span -40 to +12 dB, with 0 dB at :data:`pyquadcortex.protocol.UNITY_LEVEL` + (10/13). Delegates to :func:`pyquadcortex.protocol.lane_level_db`. + + The bottom of the knob is a detent, not a dB value: wire 0.0 reads "Off" on + screen and -39.5 dB (wire 0.01) is the lowest numeric step. This converts the + scale; it does not model the Off position. + """ + return protocol.lane_level_db(_a_number(value, "a lane level")) + + +def db_to_lane_level(db: float) -> float: + """Displayed dB as the wire value a lane, mixer or splitter LEVEL takes. + + Refuses anything outside -40..+12 dB. + + **-40.0 dB is silence, not the bottom of the knob.** It converts to wire + 0.0, which is the Off detent: the lowest NUMERIC step on the unit is -39.5 + dB, and the screen reads "Off" below it. So asking for -40 dB mutes the + lane, and anything between -40.0 and -39.5 is a reading the screen has no + way to show. For silence, write the wire's 0.0 directly and mean it. + """ + return protocol.db_to_lane_level(_a_number(db, "a lane level in dB")) + + +def tempo_bpm(value: float) -> float: + """A ``TEMPO`` wire value (0..1) as the bpm the unit displays. + + Tempo spans 40 to 240 bpm. Delegates to + :func:`pyquadcortex.protocol.tempo_bpm`, which carries the measurement and its + limits: three screen-vs-wire points, with the two endpoints coming from the + fit rather than from a driven extreme. + + A wire value outside 0..1 is REFUSED, where :func:`input_level_db` and + :func:`lane_level_db` convert one. That difference is the protocol helpers' + rather than a rule added at this seam - the tempo one refuses because the bpm + a caller would read back does not exist on the unit - and this wrapper + neither widens it nor narrows it. + """ + return protocol.tempo_bpm(_a_number(value, "a tempo wire value")) + + +def bpm_to_tempo(bpm: float) -> float: + """A displayed tempo in bpm as the wire value ``TEMPO`` takes. + + Refuses anything outside 40..240 bpm rather than clamping, because a clamped + write lands and reads back as a tempo the caller never asked for. + + This pair is a wrapper and not a home. The protocol layer calls + :func:`pyquadcortex.protocol.bpm_to_tempo` itself, inside + :meth:`~pyquadcortex.protocol.QuadCortex.set_tempo_param`, so the helper has + to stay down there: moving it up here would make the protocol layer import + the model, which is the one direction the layering forbids. + """ + return protocol.bpm_to_tempo(_a_number(bpm, "a tempo in bpm")) + + +def tuner_reference_hz(offset: float) -> float: + """The tuner's wire ``frequency`` as the absolute reference pitch on screen. + + The wire stores an OFFSET from 440 Hz, not the pitch: setting FREQ to 442 on + the unit broadcast ``frequency: 1.99999809``. The screen shows 442, so the + model does too. + + **Evidence:** that single observed pair (442 -> 2.0) is the whole of it. It + fixes the zero point and the direction; that the unit is one Hz per unit + rather than something that merely agrees at 2.0 has not been checked against + a second value on screen, and this function says so rather than implying more + (see :meth:`pyquadcortex.protocol.QuadCortex.set_tuner_reference`). No range + is enforced for the same reason: the unit's FREQ limits have not been read, + and a limit invented here would refuse a setting the unit allows. + + Nothing is rounded either, so the wire's 1.99999809 reads back as + 441.99999809 rather than the 442 on the screen. How many digits the unit's + FREQ field shows has not been read off it, and rounding to a precision + nobody has checked would be the same guess in the other direction. + """ + return CONCERT_A_HZ + _a_number(offset, "a tuner reference offset") + + +def hz_to_tuner_reference(hz: float) -> float: + """A reference pitch in Hz as the offset from 440 the wire carries. + + Inverse of :func:`tuner_reference_hz`; see it for the evidence and for why + no range is enforced. + """ + return _a_number(hz, "a tuner reference pitch") - CONCERT_A_HZ + + +def hold_timing_ms(index: int) -> int: + """The wire's ``hold_timing`` index as the milliseconds the screen shows. + + Six settings, 500 to 1000 ms in 100 ms steps. The device accepts and stores + any integer in that field without validating it, so an index outside the six + means something wrote a value no screen can show - reported rather than + rounded to the nearest real setting. + """ + choices = protocol.QuadCortex.HOLD_TIMING_MS + _a_whole_number(index, "a wire hold-timing index") + if not 0 <= index < len(choices): + raise ValueError( + f"hold timing reads {index!r}, which is outside the " + f"{len(choices)} values the unit offers - something wrote an " + f"unvalidated value into it" + ) + return choices[index] + + +def ms_to_hold_timing(milliseconds: int) -> int: + """Milliseconds as the ``hold_timing`` index the wire carries. + + Only the six values the unit offers convert. Anything else is refused rather + than rounded, and that is meant literally: 500.9 ms is not 500 ms, and + ``"500"`` is not a number. The protocol layer's setter takes + ``int(milliseconds)`` and so accepts both, which is the behaviour this + docstring would otherwise be describing wrongly. + """ + choices = protocol.QuadCortex.HOLD_TIMING_MS + _a_whole_number(milliseconds, "hold timing in ms") + if milliseconds not in choices: + raise ValueError( + f"hold timing must be one of {list(choices)} ms, " + f"not {milliseconds!r}" + ) + return choices.index(milliseconds) diff --git a/pyquadcortex/protocol/client.py b/pyquadcortex/protocol/client.py index dabbab8..fa4a428 100644 --- a/pyquadcortex/protocol/client.py +++ b/pyquadcortex/protocol/client.py @@ -970,14 +970,54 @@ def read_current_preset(self, timeout: float = 15.0): scene-targeted writes silently retargets them; use this method for inspection during editing. """ + return self.read_current_preset_push(timeout=timeout).preset + + def read_current_preset_push(self, timeout: float = 15.0): + """The whole ``RecallPreset`` reply, not just the preset inside it. + + Same request and same match as :meth:`read_current_preset` - this is + where that method's work happens and it returns ``.preset`` from here - + so everything that method's docstring records about the wire applies + unchanged. Nothing new is sent. + + It exists because the reply carries ``reason`` beside the preset, and a + caller tracking state needs both from one answer. Confirmed on hardware + 2026-08-15: the connect burst's seed push sets ``action``, ``preset`` + and ``reason``, and so does the push a recall produces. + """ request_id = self._t.next_request_id() message = pa.RecallPresetMessage(action=pa.MessageAction.READ, request_id=request_id) - reply = self._t.await_broadcast( + return self._t.await_broadcast( pa.RecallPresetMessage, lambda: self._t.send(message), timeout=timeout, match=lambda m: (m.HasField("request_id") and m.request_id == request_id)) - return reply.preset + + def loaded_position(self, timeout: float = 10.0): + """Which preset slot is on the grid right now. + + ``SetlistPosition{READ}`` answers with ``folder_key``, ``position`` and + ``is_factory``, and echoes ``request_id``. Returns the whole message, so + a caller can compare all three rather than a rendered name - two + addresses are best compared as positions, since a slot NAME moves with + the footswitch mode. + + Confirmed on hardware 2026-08-15 (d14e): the READ was answered in 3 ms + with the id echoed, on the first attempt. The unit also pushes one of + these unsolicited in the connect burst and on every recall, so a state + tracker can subscribe rather than poll. + + A READ does not recall anything - contrast :meth:`recall_preset`, which + is the same message type as an UPDATE and does change what is loaded. + """ + request_id = self._t.next_request_id() + message = pa.SetlistPositionMessage(action=pa.MessageAction.READ, + request_id=request_id) + return self._t.await_broadcast( + pa.SetlistPositionMessage, lambda: self._t.send(message), + timeout=timeout, + match=lambda m: (m.HasField("request_id") + and m.request_id == request_id)) def active_scene(self, timeout: float = 10.0): """Which scene the unit is on right now, as a :class:`~pyquadcortex.protocol.Scene`. diff --git a/scripts/check_artifacts.py b/scripts/check_artifacts.py index 41cecd7..43bc496 100755 --- a/scripts/check_artifacts.py +++ b/scripts/check_artifacts.py @@ -26,7 +26,16 @@ "pyquadcortex/protocol/cli.py", "pyquadcortex/protocol/client.py", "pyquadcortex/device/device.py", - "pyquadcortex/device/translate.py", + "pyquadcortex/device/preset.py", + "pyquadcortex/device/grid.py", + "pyquadcortex/device/blocks.py", + # The translation boundary is a PACKAGE, so its `__init__` alone proves + # nothing: a packaging rule that took the directory but dropped its modules + # would ship a boundary that re-exports names it no longer has. So a real + # converter module is named beside it. + "pyquadcortex/device/translate/__init__.py", + "pyquadcortex/device/translate/coordinates.py", + "pyquadcortex/device/translate/grid.py", # The two files that DECIDE what `import pyquadcortex` hands back. Ship a # wheel without either and every module above is still present and correct, # while the package exports nothing. diff --git a/tests/conftest.py b/tests/conftest.py index ea881a3..c0954a3 100644 --- a/tests/conftest.py +++ b/tests/conftest.py @@ -18,3 +18,4 @@ def pytest_addoption(parser): help="run the hardware-in-the-loop suite against a connected Quad Cortex " "(ADR-0005). Requires Cortex Control to be quit. Never used in CI.", ) + diff --git a/tests/fixtures/presets/make_split_preset.py b/tests/fixtures/presets/make_split_preset.py new file mode 100644 index 0000000..7d5c26f --- /dev/null +++ b/tests/fixtures/presets/make_split_preset.py @@ -0,0 +1,52 @@ +#!/usr/bin/env python3 +"""Derive a preset fixture that HAS branches from the one that has none. + +``structural_preset.bin`` came off a real Quad Cortex, but it is serial on every +row - ``protocol.splits`` reports nothing for it. So nothing in the offline suite +exercised the split half of the grid, and a reader that ignored ``mix`` entirely +passed every test. + +Rather than invent a preset shape, this sets the two branch shapes the protocol +layer already records from hardware, in ``QuadCortex.splits``' own docstring: + +* row 0 branches at column 2 and NEVER REJOINS - factory "Strat Ambience" (05B) +* row 2 branches and rejoins - factory "Darkglass AO900 1" (27H) does this on + both its branching rows. Here at columns 3 and 4, so that the splitter slot and + the mixer slot are different numbers and a reader that returned one for the + other would be caught. + +Everything else is the real fixture's own bytes. Only ``split_control_points`` +and the name are touched, so the padding, presence flags, scene-mode flags and +routing all stay verbatim. + +Re-run this if the source fixture changes:: + + .venv/bin/python tests/fixtures/presets/make_split_preset.py + +``tests/test_translation.py`` checks the result still has the two shapes, so a +fixture regenerated from a different source cannot quietly stop testing splits. +""" + +import pathlib + +from pyquadcortex.protocol.proto import Preset_pb2 as preset_pb + +HERE = pathlib.Path(__file__).parent + + +def main(): + payload = preset_pb.BinaryPreset() + payload.ParseFromString((HERE / "structural_preset.bin").read_bytes()) + payload.name = "Split Fixture" + # Wire rows 0 and 2, which are screen rows 1 and 3 - the only two that can + # carry a branch at all. + payload.chains[0].split_control_points[0].split = 2 + payload.chains[0].split_control_points[0].mix = -1 + payload.chains[2].split_control_points[0].split = 3 + payload.chains[2].split_control_points[0].mix = 4 + (HERE / "split_preset.bin").write_bytes(payload.SerializeToString()) + print(f"wrote {HERE / 'split_preset.bin'}") + + +if __name__ == "__main__": + main() diff --git a/tests/fixtures/presets/split_preset.bin b/tests/fixtures/presets/split_preset.bin new file mode 100644 index 0000000..a1e5871 Binary files /dev/null and b/tests/fixtures/presets/split_preset.bin differ diff --git a/tests/hardware/test_preset_surface.py b/tests/hardware/test_preset_surface.py new file mode 100644 index 0000000..ec56320 --- /dev/null +++ b/tests/hardware/test_preset_surface.py @@ -0,0 +1,312 @@ +"""The model's preset surface, against a real unit. + +The acceptance criterion this exists for: read a real preset through the model +and assert it matches what the protocol layer reports for the SAME preset - +blocks, positions, routing, splits and scene labels. Two accounts of one payload, +one in the wire's numbering and one in the screen's, held against each other. + +The offline suite checks the same conversions against a fixture. What it cannot +check is that the fixture still looks like what the unit sends, which is the gap +this closes. + +Read-only except for one test, which activates a scene and puts it back. Nothing +here saves anything, so a run that dies badly leaves nothing a preset recall does +not undo. +""" + +import pytest + +from pyquadcortex import protocol +from pyquadcortex.device import translate +from pyquadcortex.device.device import Device +from pyquadcortex.device.grid import SplittableRow + + +@pytest.fixture(scope="session") +def device(qc, model_cache): + """The model, on the run's one connection and its already-warm cache. + + Not closed here. The cache belongs to the session fixture, and closing this + would close it out from under every test that runs afterwards. + """ + return Device(qc, _state=model_cache) + + +@pytest.fixture(scope="session") +def wire(qc): + """The same preset, straight off the wire, read once.""" + return qc.read_current_preset() + + +# -- the model and the protocol layer agree about one real preset ------------- + + +def test_the_model_reads_the_preset_the_unit_has_loaded(device, qc): + assert device.preset is not None + assert device.preset.name == qc.read_current_preset().name + + +def test_every_block_is_where_the_protocol_layer_says_it_is(device, wire): + """Cell by cell, both ways. `blocks()` reports rows 0-3 and columns 0-7; + the model reports rows 1-4 and slots 1-8. Nothing else may differ.""" + expected = { + (translate.row_from_wire(b.row), translate.slot_from_wire(b.column)): + b.model_id + for b in protocol.blocks(wire) + } + assert expected, "the loaded preset holds no blocks - load one that does" + + found = {(block.row, block.slot): block.device.id + for block in device.preset.blocks} + assert found == expected + + +def test_the_grid_finds_the_same_blocks_cell_by_cell(device, wire): + """The mapping above compared two collections. This asks the model for each + cell by name, which is what a caller actually writes.""" + expected = {(translate.row_from_wire(b.row), translate.slot_from_wire(b.column)) + for b in protocol.blocks(wire)} + grid = device.preset.blocks + for row in translate.ROWS: + for slot in translate.SLOTS: + block = grid[row, slot] + if (row, slot) in expected: + assert block is not None, f"row {row} slot {slot} reads empty" + else: + assert block is None, f"row {row} slot {slot} reads occupied" + + +def test_every_row_reports_the_routing_the_protocol_layer_does(device, wire): + for row in device.preset.rows: + chain = wire.chains[translate.row_to_wire(row.number)] + expected_in = (protocol.Input(chain.in_portid) + if protocol.field_present(chain, "in_portid") else None) + expected_out = (protocol.Output(chain.out_portid) + if protocol.field_present(chain, "out_portid") else None) + assert row.input.source == expected_in, f"row {row.number} input" + assert row.output.destination == expected_out, f"row {row.number} output" + + +def test_a_row_routed_into_another_row_shows_no_lane_output(device, wire): + """As on screen. Skipped rather than faked if this preset has no such row. + + Rows are compared by NUMBER. `Row` defines no equality and `preset.rows` + rebuilds its objects on every access, so `row in some_list_of_rows` compares + identity across two different `Rows` instances and is always False - which + is how the first version of this test asserted the opposite of itself in its + second loop and never noticed, because the preset it ran against took the + skip above. + """ + into_a_row = (protocol.Output.NEXT_ROW_3, protocol.Output.NEXT_ROW_4, + protocol.Output.NEXT_ROW_3_4) + routed = {row.number for row in device.preset.rows + if row.output.destination in into_a_row} + if not routed: + pytest.skip("the loaded preset routes no row into another row") + for row in device.preset.rows: + if row.number in routed: + assert row.output.lane is None, f"row {row.number} feeds a row" + elif row.output.destination is not None: + assert row.output.lane is not None, f"row {row.number} feeds a jack" + + +def test_every_split_matches_the_protocol_layer(device, wire): + expected = { + translate.row_from_wire(split.row): ( + translate.slot_from_wire(split.split_column), + translate.slot_from_wire(split.mix_column) if split.rejoins else None, + translate.row_from_wire(split.lane_row), + ) + for split in protocol.splits(wire) + } + for row in device.preset.rows: + if not isinstance(row, SplittableRow): + continue + if row.number not in expected: + assert row.splitter is None and row.mixer is None + continue + at, rejoins_at, path_b = expected[row.number] + assert row.splitter is not None and row.splitter.slot == at + if rejoins_at is None: + assert row.mixer is None + else: + assert row.mixer is not None and row.mixer.slot == rejoins_at + assert row.path_b.number == path_b + + +def test_the_split_coverage_is_not_vacuous(wire): + """The test above passes trivially on a preset with no branches. This says + out loud whether the loaded preset exercised it.""" + if not protocol.splits(wire): + pytest.skip("the loaded preset has no branch, so the split assertions " + "above proved nothing - load one that branches to cover it") + + +def test_every_scene_label_matches(device, wire): + for scene in device.preset.scenes: + stored = wire.scene_labels[translate.scene_to_wire(scene.letter)] + expected = "" if not stored.strip() else stored + assert scene.name == expected, f"scene {scene.letter}" + + +def test_the_active_scene_matches_what_the_unit_reports(device, qc): + assert device.preset.scenes.active.letter == \ + translate.scene_from_wire(qc.active_scene()) + + +def test_bypass_matches_the_protocol_layer_in_every_scene(device, wire): + for block in device.preset.blocks: + stored = protocol.bypass_state(wire, + translate.row_to_wire(block.row), + translate.slot_to_wire(block.slot)) + for scene in device.preset.scenes: + through = scene.blocks[block.row, block.slot] + assert through.bypassed is \ + stored.scenes[translate.scene_to_wire(scene.letter)], \ + f"row {block.row} slot {block.slot} scene {scene.letter}" + + +# -- the two bindings cannot disagree ----------------------------------------- + + +def test_the_active_scene_s_two_bindings_agree(device): + preset = device.preset + active = preset.scenes.active + for block in preset.blocks: + through_scene = active.blocks[block.row, block.slot] + assert block == through_scene + assert block.device == through_scene.device + assert block.bypassed == through_scene.bypassed + + +def test_an_inactive_scene_refuses_writes_and_reads_fine(device): + from pyquadcortex.device.errors import InactiveSceneError + + preset = device.preset + inactive = next(scene for scene in preset.scenes if not scene.is_active) + assert not inactive.blocks.writable + with pytest.raises(InactiveSceneError, match=r"scene\.activate\(\)"): + inactive.blocks.check_writable() + assert len(list(inactive.blocks)) == len(list(preset.blocks)) + + +# -- the connect burst leaves the cache warm ---------------------------------- + + +def test_the_burst_delivered_every_entry_the_preset_surface_reads(burst_warmed): + """Measured 2026-08-15: the burst carries RecallPreset, SetlistPosition, + PresetDirty and Scene at about 10 s, inside ten milliseconds. Identity is + NOT in it - the unit never announces its own firmware - so that one is + expected to be empty and is asserted, to keep this from passing on a run + where the burst delivered nothing at all.""" + for name in ("preset", "scene", "dirty", "loaded"): + assert burst_warmed[name], f"the burst delivered nothing for {name}" + assert not burst_warmed["identity"], ( + "identity arrived in the burst, which contradicts what the entry's " + "docstring says the unit does") + + +def test_reading_the_preset_surface_costs_no_round_trip(device, model_cache): + """The NFR: nothing the burst already delivered is re-read. + + Counted at the transport, wrapping all three ways the model can ask, and the + counter is proved able to see a read at the end of this file. + """ + with counting(device) as asked: + # device.preset is INSIDE the block. It is the only path that reads the + # loaded slot, and that entry is the one where warmth is a real + # assumption rather than a measurement: SetlistPosition has seven + # presence-bearing fields and the entry keeps three, so if the burst's + # push carries any of the other four the entry is marked on arrival and + # every device.preset costs a round trip. An earlier version built the + # preset outside the block and could not have seen that. + preset = device.preset + preset.has_unsaved_changes + preset.is_current + preset.name + preset.scenes.active + assert asked == [], f"the model asked the unit for {asked}" + + +def test_the_burst_push_carries_only_fields_the_loaded_entry_keeps(handshake_burst, + model_cache): + """The assumption the test above rests on, checked directly. + + If the unit's unsolicited SetlistPosition carried `is_downloads` or any of + the other fields `_LOADED` does not keep, the entry would be marked the + moment it arrived and the warmth claim would be false - so this asserts the + entry came out of the burst trusted rather than merely populated. + """ + assert model_cache.cached("loaded"), "the burst delivered no loaded slot" + assert not model_cache.needs_read("loaded"), ( + "the burst's SetlistPosition named a field the loaded entry does not " + "keep, so device.preset costs a round trip on every connection") + + +def test_the_read_counter_can_see_a_read(device, model_cache): + """Every "asked == []" above rests on this.""" + model_cache.mark_for_reread("dirty", "proving the counter works") + with counting(device) as asked: + device.preset.has_unsaved_changes + assert asked, "the counter saw nothing where a read certainly happened" + + +class counting: + """Records every message the model sends while the block runs.""" + + def __init__(self, device): + self._device = device + self._asked = [] + + def __enter__(self): + client = self._device.client + self._real = client._t + asked = self._asked + + class Counting: + def __init__(self, inner): + self._inner = inner + + def __getattr__(self, name): + return getattr(self._inner, name) + + def send(self, message): + asked.append(type(message).__name__) + return self._inner.send(message) + + def request(self, message, *args, **kwargs): + asked.append(type(message).__name__) + return self._inner.request(message, *args, **kwargs) + + def await_broadcast(self, cls, trigger, *args, **kwargs): + asked.append(cls.__name__) + return self._inner.await_broadcast(cls, trigger, *args, **kwargs) + + client._t = Counting(self._real) + return self._asked + + def __exit__(self, *exc): + self._device.client._t = self._real + return False + + +# -- the one write, and it puts the unit back --------------------------------- + + +def test_activating_a_scene_lands_and_is_confirmed(device, qc, restores): + """The model's first write. Audible: the unit changes scene and changes + back.""" + from pyquadcortex.device.watch import WatchOutcome + + preset = device.preset + was = preset.scenes.active.letter + restores(f"the active scene ({was})", lambda: qc.switch_scene( + translate.scene_to_wire(was))) + + target = next(scene for scene in preset.scenes if scene.letter != was) + watch = target.activate() + assert watch.settled(timeout=5.0), "the unit never echoed the scene switch" + assert watch.outcome is WatchOutcome.CONFIRMED, watch.disagreement + assert translate.scene_from_wire(qc.active_scene()) == target.letter + assert preset.scenes.active.letter == target.letter + assert target.blocks.writable, "the scene we just switched to reads read-only" diff --git a/tests/test_client.py b/tests/test_client.py index b926255..f3a884b 100644 --- a/tests/test_client.py +++ b/tests/test_client.py @@ -1263,6 +1263,53 @@ def test_read_current_preset_uses_recallpreset_read_and_request_id(): assert match(pa.RecallPresetMessage()) is False +def test_read_current_preset_push_hands_back_the_whole_reply(): + """`read_current_preset` returns the preset inside the reply; the state + layer needs `reason` beside it, and both come from one answer. Same request + and same match either way - this is where that method does its work.""" + push = pa.RecallPresetMessage(action=pa.MessageAction.UPDATE, request_id=1, + reason=pa.RecallPresetReason.OTHER) + push.preset.name = "live state" + qc = client.QuadCortex(StateTransport(push)) + got = qc.read_current_preset_push() + assert got.preset.name == "live state" + assert got.reason == pa.RecallPresetReason.OTHER + asked = qc._t.sent[-1] + assert isinstance(asked, pa.RecallPresetMessage) + assert asked.action == pa.MessageAction.READ + assert asked.HasField("request_id") + + +def test_loaded_position_reads_the_slot_without_recalling_it(): + """`SetlistPosition{READ}`. The same message type as a recall, and the + action is the whole difference between asking and loading - so the wire + shape is asserted rather than assumed. + + Confirmed on hardware 2026-08-15 (d14e): answered in 3 ms with the request + id echoed, on the first attempt. + """ + push = pa.SetlistPositionMessage(action=pa.MessageAction.UPDATE, request_id=1, + folder_key="/media/p4/Presets/My Presets", + position=218, is_factory=False) + qc = client.QuadCortex(StateTransport(push)) + got = qc.loaded_position() + assert got.position == 218 + assert got.folder_key == "/media/p4/Presets/My Presets" + asked = qc._t.sent[-1] + assert isinstance(asked, pa.SetlistPositionMessage) + assert asked.action == pa.MessageAction.READ, ( + "an UPDATE here would RECALL the slot rather than ask about it") + assert not asked.HasField("folder_key"), ( + "a READ names no slot - it asks which one is loaded") + assert not asked.HasField("position") + match = qc._t.matches[-1] + assert match(push) is True + assert match(pa.SetlistPositionMessage(request_id=999)) is False + assert match(pa.SetlistPositionMessage()) is False, ( + "the burst pushes one of these with no request_id at all, and taking " + "that for our answer is how a read returns somebody else's news") + + def test_active_scene_reads_and_returns_the_enum(): from pyquadcortex.protocol.enums import Scene push = pa.SceneMessage(action=pa.MessageAction.UPDATE, request_id=1, diff --git a/tests/test_events.py b/tests/test_events.py new file mode 100644 index 0000000..9fce3a0 --- /dev/null +++ b/tests/test_events.py @@ -0,0 +1,223 @@ +"""The event stream a caller subscribes to. + +Its whole reason for existing is that a subscriber may READ FROM THE UNIT when it +hears something. The unit's messages arrive on the transport's receiving thread, +and that thread is forbidden from reading (ADR-0009), so an event handed over +there could not do the one thing it is for. Hence a thread of the model's own, +and hence the test that proves the subscriber runs on it. +""" +import threading + +import pytest + +from waiting import stays_quiet, wait_for +from pyquadcortex.device import events + + +@pytest.fixture +def stream(): + made = events.EventStream() + yield made + made.close() + + +def test_a_subscriber_hears_what_is_published(stream): + seen = [] + stream.subscribe(seen.append) + stream.publish(events.Invalidated("preset", "somebody moved a block")) + wait_for(seen, 1) + assert seen[0].part == "preset" + + +def test_events_arrive_in_the_order_they_were_published(stream): + seen = [] + stream.subscribe(seen.append) + for n in range(20): + stream.publish(events.Changed("dirty", (str(n),))) + wait_for(seen, 20) + assert [e.fields[0] for e in seen] == [str(n) for n in range(20)] + + +def test_a_subscriber_runs_off_the_publishing_thread(stream): + """The point of the whole class. If this fails, a subscriber that reacts the + obvious way - go and re-read it - raises instead of working.""" + seen = [] + stream.subscribe(lambda e: seen.append(threading.current_thread().name)) + publisher = threading.current_thread().name + stream.publish(events.Changed("dirty", ("is_dirty",))) + wait_for(seen, 1) + assert seen[0] != publisher + assert seen[0] == events.EVENT_THREAD_NAME + + +def test_every_subscriber_gets_every_event(stream): + first, second = [], [] + stream.subscribe(first.append) + stream.subscribe(second.append) + stream.publish(events.Changed("dirty", ("is_dirty",))) + wait_for(first, 1) + wait_for(second, 1) + + +def test_one_subscriber_raising_does_not_rob_the_others(stream): + """One caller's bug must not cost another caller their event, and it must + not stop the stream: the event AFTER the failure has to arrive too.""" + def explode(event): + raise ValueError("this subscriber is broken") + + seen = [] + stream.subscribe(explode) + stream.subscribe(seen.append) + stream.publish(events.Changed("dirty", ("first",))) + stream.publish(events.Changed("dirty", ("second",))) + wait_for(seen, 2) + assert [e.fields[0] for e in seen] == ["first", "second"] + + +def test_unsubscribing_stops_delivery(stream): + seen = [] + off = stream.subscribe(seen.append) + off() + stream.publish(events.Changed("dirty", ("is_dirty",))) + assert stays_quiet(seen) == [] + assert len(stream) == 0 + + +def test_unsubscribing_one_leaves_the_others(stream): + gone, stays = [], [] + off = stream.subscribe(gone.append) + stream.subscribe(stays.append) + off() + stream.publish(events.Changed("dirty", ("is_dirty",))) + wait_for(stays, 1) + assert gone == [] + + +def test_unsubscribing_twice_is_harmless(stream): + off = stream.subscribe(lambda e: None) + off() + off() + assert len(stream) == 0 + + +def test_nothing_is_published_when_nobody_is_listening(stream): + """A model with no subscribers must pay nothing for the event surface. The + unit pushes its tempo on every beat of every connection, so a stream that + queued regardless would grow for the life of a script that never asked.""" + stream.publish(events.Changed("dirty", ("is_dirty",))) + seen = [] + stream.subscribe(seen.append) + assert stays_quiet(seen) == [], ( + "an event published before anyone subscribed was queued and delivered " + "late, so the stream is holding events nobody wants") + + +def test_no_thread_is_started_until_somebody_subscribes(): + made = events.EventStream() + try: + made.publish(events.Changed("dirty", ("is_dirty",))) + running = {t.name for t in threading.enumerate()} + assert events.EVENT_THREAD_NAME not in running + made.subscribe(lambda e: None) + running = {t.name for t in threading.enumerate()} + assert events.EVENT_THREAD_NAME in running + finally: + made.close() + + +def test_closing_stops_the_thread_and_refuses_new_subscribers(): + made = events.EventStream() + made.subscribe(lambda e: None) + made.close() + assert events.EVENT_THREAD_NAME not in {t.name for t in threading.enumerate()} + with pytest.raises(RuntimeError, match="closed"): + made.subscribe(lambda e: None) + + +def test_closing_drops_the_subscribers(stream): + stream.subscribe(lambda e: None) + stream.close() + assert len(stream) == 0 + + +def test_publishing_after_close_is_harmless(stream): + seen = [] + stream.subscribe(seen.append) + stream.close() + stream.publish(events.Changed("dirty", ("is_dirty",))) + assert seen == [] + + +def test_closing_twice_is_harmless(stream): + stream.subscribe(lambda e: None) + stream.close() + stream.close() + + +def test_a_listener_that_is_not_callable_is_refused_at_subscribe(stream): + """Refused when it is handed over, not on the delivery thread later, where + the traceback would name a thread the caller has never heard of.""" + with pytest.raises(TypeError): + stream.subscribe("not a function") + + +def test_the_event_types_carry_what_happened(): + assert "preset" in repr(events.Invalidated("preset", "a Grid push")) + assert events.Changed("dirty", ("is_dirty",)) == \ + events.Changed("dirty", ("is_dirty",)) + assert events.Changed("dirty", ("is_dirty",)) != \ + events.Changed("preset", ("is_dirty",)) + + +def test_an_event_says_what_it_is_in_its_repr(): + """These reach a caller's log, so they have to read as something rather + than as an object address.""" + text = repr(events.Invalidated("preset", "a Grid push changed the grid")) + assert "Invalidated" in text + assert "a Grid push changed the grid" in text + + +def test_a_subscriber_raising_outside_exception_does_not_kill_the_thread(): + """`pytest.fail()` and `sys.exit()` both raise outside Exception, and a + subscriber is arbitrary caller code - the same reasoning the transport + records for the RX thread. Letting one through ends delivery for good, and + what the caller sees is every other subscriber going quiet with no error.""" + made = events.EventStream() + try: + def exits(event): + raise SystemExit("a subscriber called sys.exit()") + + seen = [] + made.subscribe(exits) + made.subscribe(seen.append) + made.publish(events.Changed("dirty", ("first",))) + made.publish(events.Changed("dirty", ("second",))) + wait_for(seen, 2) + assert [e.fields[0] for e in seen] == ["first", "second"] + assert events.EVENT_THREAD_NAME in {t.name for t in threading.enumerate()} + finally: + made.close() + + +def test_closing_from_inside_a_subscriber_does_not_raise(): + """Closing on a disconnect is an ordinary reaction, and `device.events` + invites a subscriber to act. Joining its own thread would raise, and that + exception would abort the rest of the shutdown - including releasing the + USB interface.""" + made = events.EventStream() + failures = [] + + def closes(event): + try: + made.close() + except BaseException as exc: # noqa: BLE001 - recorded, not swallowed + failures.append(exc) + + made.subscribe(closes) + made.publish(events.Changed("dirty", ("is_dirty",))) + for _ in range(200): + if events.EVENT_THREAD_NAME not in {t.name for t in threading.enumerate()}: + break + threading.Event().wait(0.01) + assert failures == [], f"close() from a subscriber raised {failures}" + assert events.EVENT_THREAD_NAME not in {t.name for t in threading.enumerate()} diff --git a/tests/test_grid.py b/tests/test_grid.py new file mode 100644 index 0000000..d17e9b0 --- /dev/null +++ b/tests/test_grid.py @@ -0,0 +1,386 @@ +"""The grid as the screen shows it: rows 1 to 4, slots 1 to 8, blocks in cells. + +The doubles here stand in for a `Preset` deliberately. A block reads exactly +three things - the wire preset it came from, which scene the grid it was reached +through means, and the unit's catalogue of virtual devices - so a test that had +to build a whole connected device to check a slot number would be testing the +wiring instead of the numbering. +""" +import pathlib + +import pytest + +from pyquadcortex import protocol +from pyquadcortex.device import blocks as block_module +from pyquadcortex.device import errors +from pyquadcortex.device import grid as grid_module +from pyquadcortex.device.translate import SceneLetter +from pyquadcortex.protocol.proto import Preset_pb2 as preset_pb + +PRESETS = pathlib.Path(__file__).parent / "fixtures" / "presets" + + +def load(name): + payload = preset_pb.BinaryPreset() + payload.ParseFromString((PRESETS / name).read_bytes()) + return payload + + +class FakeCatalog: + """The unit's model repository, as far as a block is concerned.""" + + def __getitem__(self, model_id): + if model_id == 999999: + raise KeyError(f"no model with id {model_id} in this device's catalog") + return protocol.Model(id=model_id, name=f"device-{model_id}", + category="AMP", category_id=1) + + +class FakePreset: + """A preset as far as a grid is concerned: a wire payload, a catalogue, and + which scene is active right now.""" + + def __init__(self, wire, active=SceneLetter.A): + self.wire = wire + self.catalog = FakeCatalog() + self.active_scene = active + + +@pytest.fixture +def structural(): + return load("structural_preset.bin") + + +@pytest.fixture +def split(): + return load("split_preset.bin") + + +def live(wire, active=SceneLetter.A): + """`preset.blocks` - bound to whichever scene is active at read time.""" + return grid_module.BlockGrid(FakePreset(wire, active)) + + +def rows_of(wire, active=SceneLetter.A): + return grid_module.Rows(live(wire, active)) + + +# -- blocks read their cell --------------------------------------------------- + + +def test_a_device_block_reads_its_screen_position(structural): + block = live(structural)[1, 1] + assert block.row == 1 and block.slot == 1 + + +def test_a_device_block_names_the_virtual_device(structural): + block = live(structural)[1, 1] + assert block.device.name == "device-18010" + assert block.device.id == 18010 + assert block.device.category == "AMP" + + +def test_a_device_the_catalogue_does_not_have_says_so(structural): + """The catalogue comes FROM the unit, so a miss is a real anomaly rather + than something to paper over with a placeholder name.""" + structural.chains[0].models[0].hash = 999999 + with pytest.raises(KeyError, match="999999"): + live(structural)[1, 1].device + + +def test_bypass_reads_through_the_grid_s_scene(structural): + """Same cell, two bindings, two answers - which is the whole point of a + binding. The fixture stores the same flag in every scene, so drive them + apart first or this passes against a reader that ignores the scene.""" + cell = structural.bypass[0].colBypass[0] + cell.sceneMode = True + cell.sceneBypass[0].bypass = True + cell.sceneBypass[1].bypass = False + preset = FakePreset(structural, active=SceneLetter.A) + in_a = grid_module.BlockGrid(preset, scene=SceneLetter.A) + in_b = grid_module.BlockGrid(preset, scene=SceneLetter.B) + assert in_a[1, 1].bypassed is True + assert in_b[1, 1].bypassed is False + + +def test_an_input_block_reads_its_source(structural): + row = rows_of(structural)[1] + assert row.input.source == protocol.Input.INPUT_1 + + +def test_an_input_block_sits_outside_the_eight_slots(structural): + assert rows_of(structural)[1].input.slot is None + + +def test_an_output_routed_to_another_row_has_no_lane(structural): + """As on screen: LANE OUTPUT CONTROL is not shown for a row feeding a row.""" + output = rows_of(structural)[1].output + assert output.destination == protocol.Output.NEXT_ROW_3 + assert output.lane is None + + +def test_an_output_routed_to_a_jack_has_a_lane(structural): + output = rows_of(structural)[3].output + assert output.destination == protocol.Output.MULTIPLE + assert output.lane is not None + + +def test_two_handles_on_the_same_cell_compare_equal(structural): + preset = FakePreset(structural) + a = grid_module.BlockGrid(preset)[1, 1] + b = grid_module.BlockGrid(preset, scene=SceneLetter.A)[1, 1] + assert a == b + assert hash(a) == hash(b) + + +def test_handles_on_different_cells_do_not(structural): + grid = live(structural) + assert grid[1, 1] != grid[1, 2] + + +def test_a_block_says_where_it_is_in_its_repr(structural): + text = repr(live(structural)[1, 1]) + assert "row 1" in text and "slot 1" in text + + +# -- rows and slots, numbered as the screen numbers them ---------------------- + + +def test_there_are_four_rows(structural): + rows = rows_of(structural) + assert len(rows) == 4 + assert [r.number for r in rows] == [1, 2, 3, 4] + + +@pytest.mark.parametrize("row", [0, 5, -1]) +def test_a_row_the_screen_does_not_show_is_refused(structural, row): + with pytest.raises(ValueError, match="1 to 4"): + rows_of(structural)[row] + + +def test_rows_1_and_3_can_start_a_branch(structural): + rows = rows_of(structural) + assert isinstance(rows[1], grid_module.SplittableRow) + assert isinstance(rows[3], grid_module.SplittableRow) + + +def test_rows_2_and_4_cannot(structural): + """The type carries the rule, so `rows[2].create_split()` is something an + editor rejects rather than something that raises at run time.""" + rows = rows_of(structural) + assert not isinstance(rows[2], grid_module.SplittableRow) + assert not isinstance(rows[4], grid_module.SplittableRow) + assert not hasattr(rows[2], "splitter") + assert not hasattr(rows[4], "path_b") + + +def test_a_row_reports_all_eight_slots_whether_or_not_they_hold_anything(structural): + row = rows_of(structural)[3] + assert len(row.slots) == 8 + assert list(row.slots)[0] is None, "the fixture's row 3 slot 1 is empty" + assert list(row.slots)[1] is not None + + +def test_a_slot_reads_the_block_in_it(structural): + assert rows_of(structural)[1].slots[1].device.id == 18010 + assert rows_of(structural)[1].slots[1].slot == 1 + + +@pytest.mark.parametrize("slot", [0, 9, -1]) +def test_a_slot_the_screen_does_not_show_is_refused(structural, slot): + with pytest.raises(ValueError, match="1 to 8"): + rows_of(structural)[1].slots[slot] + + +def test_an_empty_row_still_has_eight_slots(structural): + row = rows_of(structural)[2] + assert len(row.slots) == 8 + assert list(row.slots) == [None] * 8 + + +def test_a_row_reads_its_input_and_output(structural): + row = rows_of(structural)[1] + assert row.input.source == protocol.Input.INPUT_1 + assert row.output.destination == protocol.Output.NEXT_ROW_3 + + +# -- branches ----------------------------------------------------------------- + + +def test_a_branch_that_never_rejoins_has_no_mixer(split): + row = rows_of(split)[1] + assert row.splitter is not None + assert row.splitter.slot == 3, "wire column 2 is slot 3" + assert row.mixer is None + assert row.path_b.number == 2 + + +def test_a_branch_that_rejoins_has_a_mixer(split): + row = rows_of(split)[3] + assert row.splitter.slot == 4, "wire column 3 is slot 4" + assert row.mixer is not None and row.mixer.slot == 5 + assert row.path_b.number == 4 + + +def test_a_row_with_no_branch_has_neither(structural): + row = rows_of(structural)[1] + assert row.splitter is None and row.mixer is None + + +def test_path_b_is_a_plain_row(split): + """Path B cannot itself branch, so it is not a SplittableRow.""" + assert not isinstance(rows_of(split)[1].path_b, grid_module.SplittableRow) + + +def test_path_b_is_the_row_below(split): + assert rows_of(split)[1].path_b.number == 2 + assert rows_of(split)[3].path_b.number == 4 + + +# -- the grid ----------------------------------------------------------------- + + +def test_the_grid_is_keyed_by_row_and_slot(structural): + grid = live(structural) + assert grid[1, 1] is not None + assert grid[2, 1] is None + + +def test_the_grid_refuses_a_key_that_is_not_a_cell(structural): + grid = live(structural) + with pytest.raises(TypeError, match=r"blocks\[1, 3\]"): + grid[1] + + +def test_iterating_the_grid_yields_only_occupied_cells(structural): + grid = live(structural) + found = list(grid) + assert len(found) == len(protocol.blocks(structural)) + assert all(block is not None for block in found) + assert len(grid) == len(found) + + +def test_iterating_a_row_s_slots_yields_the_empty_ones_too(structural): + """The two collections differ on purpose, and the acceptance criteria name + both: a BlockGrid iterates occupied cells, `slots` reports all eight.""" + grid = live(structural) + assert len(list(grid)) == 14 + assert sum(len(list(row.slots)) for row in grid_module.Rows(grid)) == 32 + + +def test_the_same_cell_gives_the_same_handle_through_one_binding(structural): + grid = live(structural) + assert grid[1, 1] is grid[1, 1] + + +def test_two_bindings_on_the_active_scene_agree(structural): + preset = FakePreset(structural, active=SceneLetter.A) + live_grid = grid_module.BlockGrid(preset) + fixed = grid_module.BlockGrid(preset, scene=SceneLetter.A) + assert live_grid[1, 1] == fixed[1, 1] + assert live_grid[1, 1].device == fixed[1, 1].device + assert live_grid[1, 1].bypassed == fixed[1, 1].bypassed + + +def test_a_live_binding_follows_the_active_scene(structural): + preset = FakePreset(structural, active=SceneLetter.A) + live_grid = grid_module.BlockGrid(preset) + assert live_grid.scene == SceneLetter.A + preset.active_scene = SceneLetter.C + assert live_grid.scene == SceneLetter.C + + +def test_a_fixed_binding_does_not(structural): + preset = FakePreset(structural, active=SceneLetter.A) + fixed = grid_module.BlockGrid(preset, scene=SceneLetter.B) + preset.active_scene = SceneLetter.C + assert fixed.scene == SceneLetter.B + + +def test_a_binding_refuses_a_bare_scene_number(structural): + with pytest.raises(TypeError): + grid_module.BlockGrid(FakePreset(structural), scene=1) + + +def test_the_handles_are_dropped_when_the_preset_is_re_read(structural): + """The model re-reads the whole preset after an edit, so a handle memoized + against the old payload would go on describing the block that used to be in + that cell.""" + preset = FakePreset(structural) + grid = grid_module.BlockGrid(preset) + before = grid[1, 1] + replacement = load("structural_preset.bin") + replacement.chains[0].models[0].hash = 4 + preset.wire = replacement + assert grid[1, 1] is not before + assert grid[1, 1].device.id == 4 + + +# -- writing through a scene you are not in ---------------------------------- + + +def test_a_grid_on_the_active_scene_is_writable(structural): + preset = FakePreset(structural, active=SceneLetter.A) + assert grid_module.BlockGrid(preset).writable + assert grid_module.BlockGrid(preset, scene=SceneLetter.A).writable + + +def test_a_grid_on_another_scene_is_not(structural): + preset = FakePreset(structural, active=SceneLetter.A) + assert not grid_module.BlockGrid(preset, scene=SceneLetter.B).writable + + +def test_a_live_grid_is_always_writable(structural): + """It is bound to whichever scene is active, so it cannot be on the wrong + one by construction.""" + preset = FakePreset(structural, active=SceneLetter.A) + grid = grid_module.BlockGrid(preset) + preset.active_scene = SceneLetter.G + assert grid.writable + + +def test_the_refusal_names_the_step_to_take(structural): + preset = FakePreset(structural, active=SceneLetter.A) + fixed = grid_module.BlockGrid(preset, scene=SceneLetter.B) + with pytest.raises(errors.InactiveSceneError, match=r"scene\.activate\(\)"): + fixed.check_writable() + + +def test_the_refusal_says_which_scene(structural): + preset = FakePreset(structural, active=SceneLetter.A) + fixed = grid_module.BlockGrid(preset, scene=SceneLetter.B) + with pytest.raises(errors.InactiveSceneError, match="B"): + fixed.check_writable() + + +def test_reading_through_an_inactive_binding_works_fine(structural): + preset = FakePreset(structural, active=SceneLetter.A) + fixed = grid_module.BlockGrid(preset, scene=SceneLetter.B) + assert fixed[1, 1].device.id == 18010 + assert fixed[1, 1].bypassed in (True, False) + assert len(list(fixed)) == 14 + + +def test_activating_the_scene_makes_it_writable(structural): + """The refusal is not permanent - it names a step, and taking that step + works. Without this the message would be advice nobody had checked.""" + preset = FakePreset(structural, active=SceneLetter.A) + fixed = grid_module.BlockGrid(preset, scene=SceneLetter.B) + assert not fixed.writable + with pytest.raises(errors.InactiveSceneError): + fixed.check_writable() + preset.active_scene = SceneLetter.B + assert fixed.writable + fixed.check_writable() + + +def test_a_row_whose_routing_the_preset_never_stated_refuses_to_guess(structural): + """`None` from `lane` means "this row feeds another row, so the screen shows + no lane output". A row whose out_portid the preset never carried is a + different thing, and answering None for it would turn "we do not know" into + a positive claim.""" + structural.chains[1].ClearField("out_portid") + row = rows_of(structural)[2] + assert row.output.destination is None + with pytest.raises(RuntimeError, match="does not say where row 2 goes"): + row.output.lane diff --git a/tests/test_namespace.py b/tests/test_namespace.py index e6c7323..0cd0a01 100644 --- a/tests/test_namespace.py +++ b/tests/test_namespace.py @@ -132,6 +132,21 @@ def test_the_model_is_what_the_top_level_offers(): "__version__", "connect", "Device", "protocol", "FootswitchLetter", "SceneLetter", "PresetAddress", "DeviceNotFoundError", "DeviceLostError", + # the preset surface + "Preset", "Scene", "Scenes", + # the grid + "Rows", "Row", "SplittableRow", "Slots", "BlockGrid", + # what sits in a cell + "Block", "DeviceBlock", "InputBlock", "OutputBlock", + "SplitterBlock", "MixerBlock", "LaneOutput", + "VirtualDevice", "InputSource", "OutputDestination", + # what the model noticed + "ModelEvent", "Changed", "Invalidated", + "InactiveSceneError", + # `Scene.activate()` hands one of these back, and reading its + # outcome needs the enum - a return type a caller cannot name is + # not a return type. + "WriteWatch", "WatchOutcome", } for name in pyquadcortex.__all__: assert getattr(pyquadcortex, name, None) is not None, ( diff --git a/tests/test_preset.py b/tests/test_preset.py new file mode 100644 index 0000000..87e6fcc --- /dev/null +++ b/tests/test_preset.py @@ -0,0 +1,591 @@ +"""The preset surface, and the two questions it must answer without asking. + +`has_unsaved_changes` and `is_current` are both required to read with no device +round trip, so the double here COUNTS what the model sends and the tests assert +on the count. A test that merely called the property and checked the value would +pass just as happily if answering had taken a second and a USB transfer. + +The counting is proved to work at the bottom of this file, by marking an entry +stale and watching the read appear. Without that, "the model sent nothing" is a +claim about the double rather than about the model. +""" +import pathlib + +import pytest + +from pyquadcortex import protocol +from pyquadcortex.device import errors +from pyquadcortex.device.device import Device +from pyquadcortex.device.state import DeviceState +from pyquadcortex.device.translate import SceneLetter +from pyquadcortex.protocol.proto import Preset_pb2 as preset_pb +from pyquadcortex.protocol.proto import ProductionAutomation_pb2 as pa +from waiting import wait_for + +PRESETS = pathlib.Path(__file__).parent / "fixtures" / "presets" + + +def load(name="structural_preset.bin"): + payload = preset_pb.BinaryPreset() + payload.ParseFromString((PRESETS / name).read_bytes()) + return payload + + +class FakeClient: + """The protocol connection, counting every question the model asks. + + Three methods, because those are the three the model's reads go through. A + counter watching only one of them would report a silence it never checked. + """ + + def __init__(self, preset=None, scene=0, dirty=False, position=0): + self.asked = [] + self.listeners = [] + self.switched = [] + self.fail_switch = None + self._preset = preset if preset is not None else load() + self._scene = scene + self._dirty = dirty + self._position = position + self.catalog = FakeCatalog() + + def add_listener(self, listener): + self.listeners.append(listener) + return lambda: self.listeners.remove(listener) + + def push(self, message): + for listener in list(self.listeners): + listener(message) + + # -- what the entries read through --------------------------------------- + + def read_current_preset_push(self, timeout=15.0): + self.asked.append("preset") + push = pa.RecallPresetMessage(action=pa.MessageAction.UPDATE, + reason=pa.RecallPresetReason.OTHER) + push.preset.CopyFrom(self._preset) + self.push(push) + return push + + def active_scene(self, timeout=10.0): + self.asked.append("scene") + self.push(pa.SceneMessage(action=pa.MessageAction.UPDATE, + selected_scene=self._scene)) + return protocol.Scene(self._scene) + + def preset_dirty(self, timeout=5.0): + self.asked.append("dirty") + self.push(pa.PresetDirtyMessage(action=pa.MessageAction.UPDATE, + is_dirty=self._dirty)) + return self._dirty + + def loaded_position(self, timeout=10.0): + self.asked.append("loaded") + push = pa.SetlistPositionMessage( + action=pa.MessageAction.UPDATE, position=self._position, + folder_key="/media/p4/Presets/My Presets", is_factory=False) + self.push(push) + return push + + def version(self, timeout=10.0): + self.asked.append("identity") + return pa.VersionMessage(action=pa.MessageAction.UPDATE, + app_fw_version="d14e", + device_serial_number="QCS0000001") + + # -- the one write this story has ---------------------------------------- + + def switch_scene(self, scene): + if self.fail_switch is not None: + raise self.fail_switch + self.switched.append(int(scene)) + self._scene = int(scene) + + def close(self): + pass + + +class FakeCatalog: + def __getitem__(self, model_id): + return protocol.Model(id=model_id, name=f"device-{model_id}", + category="AMP", category_id=1) + + +def the_connect_burst(client, scene=0, dirty=False, position=0): + """What the unit pushes about 10 s into a connection, in the measured order.""" + recall = pa.RecallPresetMessage(action=pa.MessageAction.UPDATE, + reason=pa.RecallPresetReason.OTHER) + recall.preset.CopyFrom(client._preset) + client.push(recall) + client.push(pa.SetlistPositionMessage( + action=pa.MessageAction.UPDATE, position=position, + folder_key="/media/p4/Presets/My Presets", is_factory=False)) + client.push(pa.PresetDirtyMessage(action=pa.MessageAction.UPDATE, + is_dirty=dirty)) + client.push(pa.SceneMessage(action=pa.MessageAction.UPDATE, + selected_scene=scene)) + + +@pytest.fixture +def warm(): + """A Device whose cache the connect burst has already filled.""" + client = FakeClient() + state = DeviceState() + state.listen_on(client) + device = Device(client, _state=state) + the_connect_burst(client) + client.asked.clear() + try: + yield device, client + finally: + device.close() + + +@pytest.fixture +def cold(): + """A Device on a connection somebody else opened: no burst, nothing cached.""" + client = FakeClient() + device = Device(client) + try: + yield device, client + finally: + device.close() + + +# -- the preset itself -------------------------------------------------------- + + +def test_a_connected_device_always_has_a_preset(warm): + device, client = warm + assert device.preset is not None + assert device.preset.name == "Structural Fixture" + + +def test_the_name_is_refused_when_the_unit_did_not_send_one(cold): + """An absent string decodes as "" and reporting that would be a guess.""" + device, client = cold + client._preset.ClearField("name") + with pytest.raises(RuntimeError, match="no name"): + device.preset.name + + +def test_the_same_preset_object_comes_back_while_it_is_the_loaded_one(warm): + device, client = warm + assert device.preset is device.preset + + +def test_a_recall_makes_the_device_build_a_fresh_preset(warm): + device, client = warm + held = device.preset + client.push(pa.SetlistPositionMessage( + action=pa.MessageAction.UPDATE, position=17, + folder_key="/media/p4/Presets/My Presets", is_factory=False)) + assert device.preset is not held + assert not held.is_current + assert device.preset.is_current + + +def test_an_edit_does_not(warm): + """Someone turning a knob is still the same preset - only the model's copy + of its contents is behind.""" + device, client = warm + held = device.preset + client.push(pa.GridMessage(action=pa.MessageAction.UPDATE)) + assert device.preset is held + assert held.is_current + + +def test_reading_the_preset_again_after_an_edit_asks_the_unit(warm): + """The other half of the same decision: the identity is unchanged, so the + object stands, but the contents are re-read.""" + device, client = warm + device.preset.name + assert client.asked == [] + client.push(pa.GridMessage(action=pa.MessageAction.UPDATE)) + device.preset.name + assert client.asked == ["preset"] + + +# -- the two questions that must not cost a round trip ------------------------ + + +def test_has_unsaved_changes_reads_from_a_warm_cache(warm): + device, client = warm + assert device.preset.has_unsaved_changes is False + assert client.asked == [], "the burst already delivered this" + + +def test_is_current_reads_from_a_warm_cache(warm): + device, client = warm + preset = device.preset + client.asked.clear() + assert preset.is_current is True + assert client.asked == [] + + +def test_is_current_costs_nothing_even_when_the_contents_are_stale(warm): + """The identity and the contents are different questions. Marking the + contents must not make asking about the identity a round trip.""" + device, client = warm + preset = device.preset + client.push(pa.GridMessage(action=pa.MessageAction.UPDATE)) + client.asked.clear() + assert preset.is_current is True + assert client.asked == [] + + +# -- scenes ------------------------------------------------------------------- + + +def test_scenes_are_keyed_by_letter(warm): + device, client = warm + assert device.preset.scenes["B"].letter is SceneLetter.B + assert device.preset.scenes[SceneLetter.H].letter is SceneLetter.H + + +def test_a_bare_scene_number_is_refused(warm): + """Scene B is wire index 1, so a number here reads as either one.""" + device, client = warm + with pytest.raises(TypeError): + device.preset.scenes[1] + + +def test_a_letter_no_scene_carries_is_refused(warm): + device, client = warm + with pytest.raises(ValueError, match="A to H"): + device.preset.scenes["I"] + + +def test_there_are_eight_scenes(warm): + device, client = warm + assert len(device.preset.scenes) == 8 + assert [s.letter for s in device.preset.scenes] == list("ABCDEFGH") + + +def test_a_scene_reads_its_name(warm): + device, client = warm + assert device.preset.scenes["B"].name == "Scene B" + + +def test_an_unlabelled_scene_reads_as_no_name(cold): + device, client = cold + client._preset.scene_labels[1] = protocol.SCENE_UNLABELLED + assert device.preset.scenes["B"].name == "" + + +def test_the_active_scene_reads_from_the_cache(warm): + device, client = warm + assert device.preset.scenes.active.letter is SceneLetter.A + assert client.asked == [] + + +def test_the_active_scene_follows_the_unit(warm): + device, client = warm + client.push(pa.SceneMessage(action=pa.MessageAction.UPDATE, selected_scene=2)) + assert device.preset.scenes.active.letter is SceneLetter.C + assert device.preset.scenes["C"].is_active + assert not device.preset.scenes["A"].is_active + + +# -- the two grid bindings ---------------------------------------------------- + + +def test_preset_blocks_is_live_bound_to_the_active_scene(warm): + """The grid is HELD across the scene change on purpose. `preset.blocks` is a + property, so asking again would build a fresh grid and a binding pinned at + construction would pass - which is what the first version of this test did. + """ + device, client = warm + blocks = device.preset.blocks + assert blocks.scene is SceneLetter.A + client.push(pa.SceneMessage(action=pa.MessageAction.UPDATE, selected_scene=5)) + assert blocks.scene is SceneLetter.F + + +def test_scene_blocks_is_fixed_to_its_own_scene(warm): + device, client = warm + preset = device.preset + fixed = preset.scenes["B"].blocks + client.push(pa.SceneMessage(action=pa.MessageAction.UPDATE, selected_scene=5)) + assert fixed.scene is SceneLetter.B + + +def test_the_two_bindings_agree_on_the_active_scene(warm): + device, client = warm + preset = device.preset + assert preset.blocks[1, 1] == preset.scenes["A"].blocks[1, 1] + assert preset.blocks[1, 1].device == preset.scenes["A"].blocks[1, 1].device + + +def test_the_rows_read_through_the_preset(warm): + device, client = warm + rows = device.preset.rows + assert len(rows) == 4 + assert rows[1].slots[1].device.id == 18010 + assert rows[1].input.source == protocol.Input.INPUT_1 + + +def test_an_inactive_scene_s_grid_refuses_writes(warm): + device, client = warm + fixed = device.preset.scenes["B"].blocks + assert not fixed.writable + with pytest.raises(errors.InactiveSceneError, match=r"scene\.activate\(\)"): + fixed.check_writable() + + +def test_reading_through_an_inactive_scene_works_fine(warm): + device, client = warm + fixed = device.preset.scenes["B"].blocks + assert fixed[1, 1].device.id == 18010 + assert len(list(fixed)) == 14 + + +# -- activate: the model's first write ---------------------------------------- + + +def test_activate_sends_the_switch(warm): + device, client = warm + device.preset.scenes["C"].activate() + assert client.switched == [protocol.Scene.C] + + +def test_activate_updates_the_cache_before_any_echo(warm): + """Section 9's third rule. Waiting for the echo would make every write pay + for information we almost always already have.""" + device, client = warm + device.preset.scenes["C"].activate() + assert device.state.cached("scene")["selected_scene"] == protocol.Scene.C + assert device.preset.scenes.active.letter is SceneLetter.C + + +def test_a_matching_echo_confirms_the_write(warm): + device, client = warm + from pyquadcortex.device.watch import WatchOutcome + + watch = device.preset.scenes["C"].activate() + client.push(pa.SceneMessage(action=pa.MessageAction.UPDATE, selected_scene=2)) + assert watch.settled(timeout=2.0) + assert watch.outcome is WatchOutcome.CONFIRMED + assert watch.disagreement is None + + +def test_an_echo_the_unit_disagrees_with_is_reported(warm): + """A write the unit contradicted is a bug in our code, now with a name and a + location - and the entry is marked, because any OTHER field we sent is still + sitting in the cache on our say-so.""" + from pyquadcortex.device.watch import WatchOutcome + + device, client = warm + watch = device.preset.scenes["C"].activate() + client.push(pa.SceneMessage(action=pa.MessageAction.UPDATE, selected_scene=6)) + assert watch.settled(timeout=2.0) + assert watch.outcome is WatchOutcome.DIFFERENT + assert watch.disagreement[0] == "selected_scene" + assert device.state.needs_read("scene") + + +def test_a_write_that_never_reaches_the_unit_marks_the_scene(warm): + """Our copy would otherwise be the only place that value exists.""" + device, client = warm + client.fail_switch = protocol.DeviceLostError("the cable came out") + with pytest.raises(protocol.DeviceLostError): + device.preset.scenes["C"].activate() + assert device.state.needs_read("scene") + + +def test_activating_the_scene_already_active_still_writes(warm): + """The unit is the authority on what is active. Skipping the write because + the model believes it is already there would make the model the authority + instead, and it would be wrong the first time the two disagreed.""" + device, client = warm + assert device.preset.scenes.active.letter is SceneLetter.A + device.preset.scenes["A"].activate() + assert client.switched == [protocol.Scene.A] + + +def test_activating_makes_that_scene_s_grid_writable(warm): + """The refusal names a step; this is the step working.""" + device, client = warm + fixed = device.preset.scenes["B"].blocks + assert not fixed.writable + device.preset.scenes["B"].activate() + assert fixed.writable + fixed.check_writable() + + +# -- a closed device answers nothing ----------------------------------------- + + +def test_a_closed_device_refuses_its_preset(warm): + device, client = warm + device.close() + with pytest.raises(RuntimeError, match="closed"): + device.preset + + +def test_a_preset_held_across_close_answers_nothing(warm): + device, client = warm + preset = device.preset + device.close() + with pytest.raises(RuntimeError, match="closed"): + preset.name + + +def test_a_closed_device_stops_delivering_events(warm): + device, client = warm + device.events.subscribe(lambda e: None) + device.close() + with pytest.raises(RuntimeError, match="closed"): + device.events.subscribe(lambda e: None) + + +# -- the counter that the "no round trip" tests rest on ---------------------- + + +def test_the_counter_can_see_a_read(warm): + """Every "asked == []" above is worthless if this fails.""" + device, client = warm + device.state.mark_for_reread("dirty", "proving the counter works") + device.preset.has_unsaved_changes + assert client.asked == ["dirty"] + + +def test_a_cold_device_really_does_ask(warm): + """And the same, for a connection with no burst behind it.""" + client = FakeClient() + device = Device(client) + try: + assert device.preset.name == "Structural Fixture" + assert "loaded" in client.asked and "preset" in client.asked + finally: + device.close() + + +def test_an_edit_reaches_a_subscriber(warm): + device, client = warm + seen = [] + device.events.subscribe(seen.append) + client.push(pa.GridMessage(action=pa.MessageAction.UPDATE)) + wait_for(seen, 1) + assert seen[0].part == "preset" + + +# -- a preset that is no longer loaded answers nothing ------------------------ +# +# The failure this closes: `is_current` went False while `name`, `blocks` and +# the rest went on reading live state - so a held Preset reported the NEW +# preset's contents. Worse than reporting the old ones, and the exact shape +# `Device._check_open` refuses for a closed connection. + + +def recall_elsewhere(client, name="SOMETHING ELSE", position=99): + """The unit loading a different preset, as it really announces it.""" + other = load() + other.name = name + client._preset = other + client.push(pa.SetlistPositionMessage( + action=pa.MessageAction.UPDATE, position=position, + folder_key="/media/p4/Presets/My Presets", is_factory=False)) + recall = pa.RecallPresetMessage(action=pa.MessageAction.UPDATE, + reason=pa.RecallPresetReason.OTHER) + recall.preset.CopyFrom(other) + client.push(recall) + + +def test_a_stale_preset_does_not_report_the_new_presets_name(warm): + device, client = warm + held = device.preset + assert held.name == "Structural Fixture" + recall_elsewhere(client) + assert not held.is_current + with pytest.raises(RuntimeError, match="no longer the one on the grid"): + held.name + + +@pytest.mark.parametrize("read", [ + lambda p: p.name, + lambda p: p.wire, + lambda p: p.has_unsaved_changes, + lambda p: p.active_scene, + lambda p: p.blocks[1, 1], + lambda p: list(p.blocks), + lambda p: p.rows[1].slots[1], + lambda p: p.rows[1].input.source, + lambda p: p.scenes["B"].name, + lambda p: p.scenes.active, + lambda p: p.scenes["B"].blocks[1, 1], +], ids=["name", "wire", "has_unsaved_changes", "active_scene", "blocks", + "iterate blocks", "slot", "row input", "scene name", "active scene", + "scene blocks"]) +def test_every_read_through_a_stale_preset_refuses(warm, read): + device, client = warm + held = device.preset + recall_elsewhere(client) + with pytest.raises(RuntimeError, match="no longer the one on the grid"): + read(held) + + +def test_a_stale_preset_still_answers_is_current(warm): + """The one property that must not raise - asking whether an object is + still good is how a caller avoids every error above.""" + device, client = warm + held = device.preset + recall_elsewhere(client) + assert held.is_current is False + + +def test_a_stale_preset_still_has_a_repr(warm): + """repr() is called by debuggers and logging, so it must never raise.""" + device, client = warm + held = device.preset + recall_elsewhere(client) + assert "Preset" in repr(held) + + +def test_activating_a_scene_through_a_stale_preset_is_refused(warm): + """The audible half. Without this, a Scene reached through a held Preset + switches the scene of whatever is loaded NOW.""" + device, client = warm + scene = device.preset.scenes["B"] + recall_elsewhere(client) + with pytest.raises(RuntimeError, match="no longer the one on the grid"): + scene.activate() + assert client.switched == [], "the unit was told to switch anyway" + + +def test_the_device_hands_back_a_working_preset_after_the_recall(warm): + """The refusal has to leave the caller somewhere to go.""" + device, client = warm + device.preset + recall_elsewhere(client) + assert device.preset.name == "SOMETHING ELSE" + assert device.preset.is_current + + +# -- block identity survives what the model does to the payload --------------- + + +def test_two_handles_on_a_cell_stay_equal_across_a_re_read(warm): + """Keyed on the preset, not on the payload. The model replaces the payload + on every re-read, so keying on it meant a Block put in a set was silently + lost the moment somebody touched the unit.""" + device, client = warm + preset = device.preset + before = preset.blocks[1, 1] + held = {before} + client.push(pa.GridMessage(action=pa.MessageAction.UPDATE)) + after = preset.blocks[1, 1] + assert after == before + assert after in held + + +def test_hashing_a_block_never_asks_the_unit(warm): + """`hash()` reaching through to the cache could issue a 21 KB read with a + fifteen-second timeout, and raise on a closed device.""" + device, client = warm + block = device.preset.blocks[1, 1] + device.state.mark_for_reread("preset", "this test") + client.asked.clear() + hash(block) + block == block + assert client.asked == [] diff --git a/tests/test_state.py b/tests/test_state.py index 80adf77..c77536f 100644 --- a/tests/test_state.py +++ b/tests/test_state.py @@ -13,14 +13,17 @@ """ import collections import logging +import pathlib import threading import time import pytest -from pyquadcortex.device import entries, state +from pyquadcortex.device import entries, events, state +from waiting import stays_quiet, wait_for from pyquadcortex.device.watch import WatchOutcome from pyquadcortex.protocol import client as protocol_client +from pyquadcortex.protocol.proto import Preset_pb2 as preset_pb from pyquadcortex.protocol.proto import ProductionAutomation_pb2 as pa @@ -35,6 +38,7 @@ class name, and hands the reply to every listener BEFORE returning it - def __init__(self): self.replies = {} + self.broadcasts = {} self.sent = [] self.reads = collections.Counter() self.listeners = [] @@ -59,6 +63,39 @@ def send(self, message): def next_request_id(self): return next(self._ids) + def await_broadcast(self, message_class, trigger, timeout=5.0, match=None): + """The reads that wait for a PUSH rather than for a reply. + + `read_current_preset` and `active_scene` both work this way: they send a + READ and wait for the broadcast that echoes its request id. Replies live + in :attr:`broadcasts` rather than :attr:`replies`, keyed the same way, + and are called with the message that triggered them so a canned answer + can echo the id the real one would. + + The reply is held to the caller's own ``match`` before it is handed + back. Without that, a test could set an answer the real code would have + rejected and never know - which is the failure mode that makes a double + worse than no test. + """ + name = message_class.__name__ + self.reads[name] += 1 + trigger() + try: + reply = self.broadcasts[name] + except KeyError: # pragma: no cover - a test bug + raise AssertionError( + f"the test asked the unit for a {name} broadcast and set no " + f"reply for it") + if callable(reply): + reply = reply(self.sent[-1] if self.sent else None) + if match is not None and not match(reply): + raise AssertionError( + f"the canned {name} does not satisfy the match the real read " + f"uses, so this test is proving something the library would " + f"have rejected") + self.push(reply) + return reply + def request(self, message, timeout=5.0): name = type(message).__name__ self.sent.append(message) @@ -111,6 +148,17 @@ def tempo_pair(): return beat, status +def _carrying_something(message, field): + """``message`` with ``field`` actually set, so presence reports it. + + A submessage is only present once something in it is touched, and an + unset one would make the copy check below pass by applying nothing. + """ + message.ClearField(field) + getattr(message, field).SetInParent() + return message + + def with_an_unknown_field(message, number=999, value=7): """``message`` re-parsed with a field number the recovered schema lacks. @@ -130,12 +178,70 @@ def with_an_unknown_field(message, number=999, value=7): return grown +PRESET_FIXTURE = (pathlib.Path(__file__).parent / "fixtures" / "presets" + / "structural_preset.bin") + + +def a_preset(): + """The structural fixture, read off a real unit, as a `BinaryPreset`.""" + payload = preset_pb.BinaryPreset() + payload.ParseFromString(PRESET_FIXTURE.read_bytes()) + return payload + + +def recall_push(triggering=None, preset=None): + """A `RecallPreset` carrying a whole preset, echoing a read's request id. + + Every one of these carries `reason` as well - a host recall and a plain READ + both report OTHER - which is why the preset entry keeps it. + """ + push = pa.RecallPresetMessage(action=pa.MessageAction.UPDATE, + reason=pa.RecallPresetReason.OTHER) + push.preset.CopyFrom(a_preset() if preset is None else preset) + if triggering is not None and triggering.HasField("request_id"): + push.request_id = triggering.request_id + return push + + +def scene_push(triggering=None, scene=0): + push = pa.SceneMessage(action=pa.MessageAction.UPDATE, selected_scene=scene) + if triggering is not None and triggering.HasField("request_id"): + push.request_id = triggering.request_id + return push + + +def grid_push(action=pa.MessageAction.UPDATE): + """What one edit on the touchscreen produces about forty of.""" + return pa.GridMessage(action=action) + + +def position_push(triggering=None, position=9): + """The loaded slot as an answer to a READ, echoing the request id.""" + push = recalled_elsewhere(position) + if triggering is not None and triggering.HasField("request_id"): + push.request_id = triggering.request_id + return push + + +def recalled_elsewhere(position=9): + """The unit announcing which preset is loaded. + + The exact shape the connect burst delivers, measured 2026-08-15: action, + folder_key, is_factory and position, with no request_id.""" + return pa.SetlistPositionMessage(action=pa.MessageAction.UPDATE, + folder_key="/media/p4/Presets/My Presets", + position=position, is_factory=False) + + @pytest.fixture def link(): """A cache listening on a loopback link, over the real protocol client.""" transport = LoopbackTransport() transport.replies["VersionMessage"] = full_version_reply transport.replies["PresetDirtyMessage"] = lambda: dirty_push(False) + transport.broadcasts["RecallPresetMessage"] = recall_push + transport.broadcasts["SceneMessage"] = scene_push + transport.broadcasts["SetlistPositionMessage"] = position_push qc = protocol_client.QuadCortex(transport) cache = state.DeviceState() cache.listen_on(transport) @@ -495,25 +601,71 @@ def test_no_field_an_entry_leaves_unkept_is_invisible_on_the_wire(entry): ``is_dirty`` is kept. """ for message_class, plan in entry.feeds.items(): + if plan.voids_the_copy(): + # This plan has no blind spot, because it does not look. Every + # message of this type makes the entry untrusted whatever it + # carries, which is a STRONGER answer than keeping the field: it + # cannot be fooled by a field the wire renders as nothing. That is + # exactly why `Grid` and `SceneLabel` are declared this way - see + # `FieldPlan.invalidates`. + continue declared = plan.kept | plan.no_presence | entries.SCAFFOLDING invisible = sorted(field.name for field in message_class.DESCRIPTOR.fields if not field.has_presence and field.name not in declared) assert not invisible, ( f"{entry.name} does not keep {message_class.__name__}.{invisible}, " f"which the wire cannot report as absent - so a change to it is " - f"undetectable rather than merely unkept") + f"undetectable rather than merely unkept. Either keep it with the " + f"evidence for what its default means, as `is_dirty` is kept, or " + f"declare the whole type as voiding the copy") + + +@pytest.mark.parametrize("entry", entries.ENTRIES, ids=lambda e: e.name) +def test_a_plan_that_voids_the_copy_really_does_it_unconditionally(entry): + """The exemption above has to be load-bearing. + + A plan skipped there is trusted to mark the entry from an EMPTY message, + because that is what the types it exists for can look like: a `Grid` UPDATE + with nothing but its action, or a `SceneLabel` renaming scene A to a blank + label, both of which set nothing in `ListFields()`. If the flag ever stopped + doing that, the skip above would be forgiving a real blind spot. + """ + for message_class, plan in entry.feeds.items(): + if not plan.voids_the_copy(): + continue + # Asserted on the PLAN, not on a message. An earlier version built an + # empty message and checked `fields_applied` was empty - which it is for + # any presence-bearing field regardless of what the plan declares, so + # the assertion held for a plan keeping two fields and proved nothing. + assert not (plan.kept | plan.no_presence), ( + f"{entry.name} both voids its copy on {message_class.__name__} and " + f"keeps {sorted(plan.kept | plan.no_presence)} from it, which is " + f"two answers to one question") + # And the property the exemption actually rests on: an empty message of + # this type still marks the entry. That is what the per-field check + # cannot do, and why these types are declared by type at all. + assert entries.unkept_fields(message_class(), plan) == [] + assert plan.voids_the_copy(), ( + f"nothing would mark {entry.name} from an empty " + f"{message_class.__name__}, so the skip above forgives a real " + f"blind spot") @pytest.mark.parametrize("entry", entries.ENTRIES, ids=lambda e: e.name) -def test_every_field_an_entry_keeps_is_a_plain_value(entry): +def test_nothing_an_entry_keeps_is_a_container_the_rx_thread_owns(entry): """The cache stores what ``getattr`` hands back, and for a message or a repeated field that is a live container INSIDE the message the RX thread just decoded - shared with every other listener and read afterwards from other threads. - Scalars are copied by value, so today there is nothing to share. This fires - the first time an entry keeps something composite, which is when that has to - be answered rather than assumed. + Scalars are copied by value and need nothing. A SUBMESSAGE is copied on the + way in (``entries._held``), which is what makes the preset entry safe to + hold a whole ``BinaryPreset``. This test proves the copy really happens + rather than trusting that it does, by mutating the source afterwards. + + A repeated field is still refused outright. ``_held`` does not copy one, and + nothing needs it to yet - the day something does, that is the moment to + decide what a repeated field means in a cache rather than to discover it. """ for message_class, plan in entry.feeds.items(): for name in sorted(plan.kept | plan.no_presence): @@ -521,9 +673,17 @@ def test_every_field_an_entry_keeps_is_a_plain_value(entry): assert not field.is_repeated, ( f"{message_class.__name__}.{name} is repeated, so the cache " f"would hold a live container from a message the RX thread owns") - assert field.type not in (field.TYPE_MESSAGE, field.TYPE_GROUP), ( - f"{message_class.__name__}.{name} is a submessage, so the cache " - f"would hold a live container from a message the RX thread owns") + if field.type not in (field.TYPE_MESSAGE, field.TYPE_GROUP): + continue + source = message_class() + held = entries.fields_applied(_carrying_something(source, name), plan) + assert name in held, ( + f"{message_class.__name__}.{name} did not survive being applied") + assert held[name] is not getattr(source, name), ( + f"{entry.name} holds {message_class.__name__}.{name} BY " + f"REFERENCE. That container lives inside a message the RX " + f"thread decoded and handed to every other listener, so what " + f"the model reports would change when any of them touches it") @pytest.mark.parametrize("entry", entries.ENTRIES, ids=lambda e: e.name) @@ -872,3 +1032,464 @@ def test_no_watchdog_runs_until_something_is_written(link): def _watchdog_threads(): return [t for t in threading.enumerate() if t.name.startswith(state.WATCHDOG_THREAD_NAME)] + + +# -- the preset and the active scene ------------------------------------------ +# +# These two entries are what the grid, the scenes and `device.preset` read +# through. Both reads are one request and one reply, which is what `StateEntry` +# requires - the Directory's listings are the streaming case and they are not +# here. + + +def test_the_preset_entry_is_registered(): + assert entries.ENTRY_BY_NAME["preset"].fields() >= {"preset"} + + +def test_the_scene_entry_is_registered(): + assert entries.ENTRY_BY_NAME["scene"].fields() == {"selected_scene"} + + +def test_the_preset_entry_keeps_the_reason_every_push_carries(): + """Measured 2026-08-15: the connect burst's seed `RecallPreset` sets action, + preset AND reason, and so does the push a recall produces. An entry that did + not keep `reason` would be marked for re-reading by the very burst that + warmed it.""" + plan = entries.PRESET.feeds[pa.RecallPresetMessage] + assert plan.kept == {"preset", "reason"} + + +def test_the_seed_push_the_unit_really_sends_leaves_the_entry_trusted(): + """The shape the burst delivers, field for field, held against the plan.""" + plan = entries.PRESET.feeds[pa.RecallPresetMessage] + seed = recall_push() + assert sorted(f.name for f, _ in seed.ListFields()) == \ + ["action", "preset", "reason"], "the fixture no longer matches the unit" + assert not entries.unkept_fields(seed, plan) + + +def test_the_preset_entry_can_read_back_every_field_it_keeps(link): + """The rule that made keeping `reason` cost something: an entry that keeps + a field its read cannot answer for loses it the first time it is marked. + So the read goes through `read_current_preset_push`, which hands back the + whole reply rather than just the preset inside it.""" + transport, cache = link + cache.mark_for_reread("preset", "this test") + assert cache.value("preset", "reason") is not None + assert cache.value("preset", "preset").name == "Structural Fixture" + + +def test_a_recall_push_does_not_invalidate_the_preset_it_delivers(): + plan = entries.PRESET.feeds[pa.RecallPresetMessage] + assert not plan.voids_the_copy() + + +def test_a_grid_push_invalidates_the_preset_however_empty_it_is(): + """`action` has no presence and lives in SCAFFOLDING, so a Grid UPDATE and a + Grid DELETE with the same payload are indistinguishable to the per-field + check - and a Grid message carrying nothing else sets no fields at all. The + entry therefore does not rely on that check: every Grid push means the grid + moved, and that IS this entry's decision about `action`.""" + plan = entries.PRESET.feeds[pa.GridMessage] + assert plan.invalidates + assert not entries.unkept_fields(pa.GridMessage(), plan), ( + "an empty Grid message names nothing, which is exactly why the flag " + "and not the field check has to be what marks this entry") + + +def test_a_scene_label_push_invalidates_the_preset(): + """`SceneLabelMessage.index` and `.label` have NO presence, so renaming + scene A to a blank label sets nothing in `ListFields()` and the per-field + check sees an empty message. Scene names live in the preset payload, so our + copy of it is now wrong and nothing in the message says so.""" + plan = entries.PRESET.feeds[pa.SceneLabelMessage] + assert plan.invalidates + assert not entries.unkept_fields(pa.SceneLabelMessage(), plan) + + +def test_a_scene_colour_push_invalidates_the_preset_too(): + """The model does not expose scene colours, but it holds the whole preset + payload and `scene_colors` is inside it. There is no harmless-field + category (root CLAUDE.md), so this marks like everything else.""" + assert entries.PRESET.feeds[pa.SceneColorMessage].invalidates + + +def test_the_preset_entry_never_hears_about_the_loaded_slot(): + """`SetlistPosition` says WHICH preset is loaded, not what is in it, so it + feeds the `loaded` entry and not this one. + + Measured 2026-08-15: a recall pushes eight to thirteen `Grid` messages, then + `RecallPreset` carrying the whole new preset, then `Scene`, then + `SetlistPosition` - which arrives about 90 ms LAST. The preset entry is + already right by then, twice over. And the connect burst carries no `Grid` + pushes at all, because nothing changed there: marking the preset on this + message would throw away exactly what the burst had just delivered. + """ + assert pa.SetlistPositionMessage not in entries.PRESET.feeds + + +def test_the_loaded_slot_is_its_own_entry(): + """It used to be a counter on the preset entry, bumped whenever a recall was + seen. It is the unit's own answer now: `SetlistPosition{READ}` really does + reply - 3 ms, request id echoed, confirmed 2026-08-15 - so `is_current` can + compare a fact the unit stated rather than the model's own bookkeeping.""" + assert entries.ENTRY_BY_NAME["loaded"].fields() == { + "folder_key", "position", "is_factory"} + + +def test_a_change_of_loaded_slot_resets_the_dirty_flag_and_the_scene(): + """The measurement that mattered most: a recall pushes NO PresetDirty. It + clears the unsaved-changes flag on the unit and says nothing about it, so + without this the model would go on reporting edits the recall discarded. + + Declared on the `loaded` entry rather than as a plan on each of the other + two, because it must fire on a CHANGE of slot and not on every message of + that type - the model's own read of the loaded slot is one of those. + """ + assert entries.LOADED.resets == ("dirty", "scene") + assert pa.SetlistPositionMessage not in entries.DIRTY.feeds + assert pa.SetlistPositionMessage not in entries.SCENE.feeds + + +def test_a_recall_really_does_reset_them(link): + transport, cache = link + cache.apply_push(dirty_push(True)) + cache.apply_push(scene_push(scene=2)) + cache.apply_push(recalled_elsewhere(position=9)) + assert not cache.needs_read("dirty") and not cache.needs_read("scene"), ( + "the first sighting of a slot is not a change - there was nothing to " + "reset yet") + cache.apply_push(recalled_elsewhere(position=17)) + assert cache.needs_read("dirty") + assert cache.needs_read("scene") + + +def test_the_models_own_read_of_the_loaded_slot_resets_nothing(link): + """Asking which preset is loaded is a question, not news. An earlier version + put `invalidates` on both entries, so one `device.preset` on a cold cache + marked them and published two Invalidated events - the model reporting its + own question as something the unit had done.""" + transport, cache = link + cache.apply_push(dirty_push(True)) + cache.apply_push(scene_push(scene=2)) + seen = [] + cache.events.subscribe(seen.append) + cache.value("loaded", "position") + assert not cache.needs_read("dirty") + assert not cache.needs_read("scene") + assert [e for e in stays_quiet(seen) if isinstance(e, events.Invalidated)] == [] + + +def test_an_edit_does_not_touch_the_loaded_slot(): + """Someone turning a knob is still the same preset. Only a recall makes a + Preset object somebody is holding stale.""" + assert pa.GridMessage not in entries.LOADED.feeds + + +def test_a_scene_push_carries_the_active_scene(): + plan = entries.SCENE.feeds[pa.SceneMessage] + assert plan.kept == {"selected_scene"} + message = pa.SceneMessage(selected_scene=3) + assert entries.fields_applied(message, plan) == {"selected_scene": 3} + assert not entries.unkept_fields(message, plan) + + +def test_the_preset_read_asks_for_the_live_grid(link): + """`read_current_preset` reads what is on the grid RIGHT NOW, unsaved edits + included, with no side effects. `read_preset` would RECALL a stored slot - + interrupting the audio every time and resetting the active scene - which is + the opposite of a read.""" + transport, cache = link + cache.value("preset", "preset") + asked = [m for m in transport.sent if isinstance(m, pa.RecallPresetMessage)] + assert asked, "nothing asked the unit for the preset" + assert all(m.action == pa.MessageAction.READ for m in asked) + assert not any(isinstance(m, pa.SetlistPositionMessage) for m in transport.sent), ( + "the read recalled a slot, which changes what the unit is playing") + + +def test_a_grid_push_marks_the_preset_without_merging_anything(link): + transport, cache = link + cache.apply_push(grid_push()) + assert cache.needs_read("preset") + assert cache.cached("preset") == {} + + +def test_forty_grid_pushes_still_cost_one_read(link): + """A flag, not a queue. One edit on the touchscreen produces about forty.""" + transport, cache = link + for _ in range(40): + cache.apply_push(grid_push()) + cache.value("preset", "preset") + assert transport.reads["RecallPresetMessage"] == 1 + + +def test_the_preset_is_kept_as_a_copy_not_as_a_live_container(link): + """The cache stores what `getattr` hands back, and for a submessage that is + a container INSIDE the message the receiving thread just decoded - shared + with every other listener and read afterwards from other threads. + + So it is copied. This is the test that says so, because the structural check + below can only see that a submessage IS kept, not whether it was copied. + """ + transport, cache = link + push = recall_push() + cache.apply_push(push) + held = cache.cached("preset")["preset"] + assert held.name == "Structural Fixture" + push.preset.name = "mutated by somebody else" + assert held.name == "Structural Fixture", ( + "the cache is holding a reference into a message it does not own, so " + "anyone else who decoded or mutated it changes what the model reports") + + +def test_the_active_scene_is_read_and_then_free(link): + transport, cache = link + assert cache.value("scene", "selected_scene") == 0 + assert cache.value("scene", "selected_scene") == 0 + assert transport.reads["SceneMessage"] == 1 + + +def test_a_scene_switch_on_the_unit_reaches_the_cache(link): + transport, cache = link + cache.apply_push(scene_push(scene=3)) + assert cache.cached("scene")["selected_scene"] == 3 + assert not cache.needs_read("scene") + + +def test_a_recall_elsewhere_changes_the_loaded_slot(link): + """What `preset.is_current` compares.""" + transport, cache = link + cache.apply_push(recalled_elsewhere(position=9)) + was = cache.cached("loaded") + cache.apply_push(recalled_elsewhere(position=17)) + assert cache.cached("loaded") != was + assert cache.cached("loaded")["position"] == 17 + + +def test_an_edit_leaves_the_loaded_slot_alone(link): + """Someone turning a knob is still the same preset - our copy of its + contents is merely behind. Only a recall makes a held Preset stale.""" + transport, cache = link + cache.apply_push(recalled_elsewhere()) + was = cache.cached("loaded") + cache.apply_push(grid_push()) + assert cache.cached("loaded") == was + + +def test_reading_the_loaded_slot_from_the_cache_never_asks_the_unit(link): + """`preset.is_current` promises no round trip, and it reads through this.""" + transport, cache = link + cache.apply_push(recalled_elsewhere()) + cache.mark_for_reread("loaded", "this test") + assert cache.cached("loaded")["position"] == 9 + assert transport.reads["SetlistPositionMessage"] == 0 + + +# -- what the model tells a caller it noticed --------------------------------- + + +def test_forty_grid_pushes_produce_one_invalidated_event(link): + """Fired on the change from trusted to untrusted, not on every push.""" + transport, cache = link + seen = [] + cache.events.subscribe(seen.append) + for _ in range(40): + cache.apply_push(grid_push()) + wait_for(seen, 1) + assert stays_quiet(seen) == seen + assert len(seen) == 1 + assert isinstance(seen[0], events.Invalidated) + assert seen[0].part == "preset" + + +def test_the_next_invalidation_after_a_read_is_announced_again(link): + """The mark is cleared by a read, so the caller hears about the NEXT edit + rather than being told once per connection.""" + transport, cache = link + seen = [] + cache.events.subscribe(seen.append) + cache.apply_push(grid_push()) + wait_for(seen, 1) + cache.value("preset", "preset") + cache.apply_push(grid_push()) + wait_for(seen, 2) + + +def test_a_push_restating_what_we_already_knew_is_not_a_change(link): + """The unit pushes `PresetDirty` on every edit whether or not the answer is + new. Reporting those would make the stream useless for what it is for.""" + transport, cache = link + cache.apply_push(dirty_push(True)) + seen = [] + cache.events.subscribe(seen.append) + cache.apply_push(dirty_push(True)) + assert stays_quiet(seen) == [] + + +def test_a_push_that_moves_a_value_is_a_change(link): + transport, cache = link + cache.apply_push(dirty_push(True)) + seen = [] + cache.events.subscribe(seen.append) + cache.apply_push(dirty_push(False)) + wait_for(seen, 1) + assert seen[0] == events.Changed("dirty", ("is_dirty",)) + + +def test_the_first_time_a_value_arrives_is_a_change(link): + transport, cache = link + seen = [] + cache.events.subscribe(seen.append) + cache.apply_push(dirty_push(True)) + wait_for(seen, 1) + assert seen[0] == events.Changed("dirty", ("is_dirty",)) + + +def test_an_event_is_never_delivered_on_the_receiving_thread(link): + """A subscriber may read from the unit, and the transport refuses a read on + the thread that applies pushes (ADR-0009). So delivery cannot be on it.""" + transport, cache = link + seen = [] + cache.events.subscribe( + lambda e: seen.append(threading.current_thread().name)) + here = threading.current_thread().name + cache.apply_push(grid_push()) + wait_for(seen, 1) + assert seen[0] != here + + +def test_closing_the_cache_closes_the_event_stream(link): + transport, cache = link + cache.events.subscribe(lambda e: None) + cache.close() + with pytest.raises(RuntimeError, match="closed"): + cache.events.subscribe(lambda e: None) + + +# -- the connect burst leaves the cache genuinely warm ------------------------ +# +# Measured on hardware 2026-08-15. About 3 s of quiet, ~400 File messages, then +# at 10.04 s these four inside ten milliseconds, in this order: +# +# RecallPreset ['action', 'preset', 'reason'] +# SetlistPosition ['action', 'folder_key', 'is_factory', 'position'] +# PresetDirty ['action'] (is_dirty has no presence) +# Scene ['action', 'selected_scene'] +# +# Two of them mark an entry and the next one answers it in full. Without the +# rule that a complete push clears the mark, every one of those entries would +# cost a round trip on first access - which is what "warm for free" is supposed +# to mean, and what the acceptance criteria ask for by name. + + +def the_connect_burst(): + """The four state messages the burst delivers, in the measured order.""" + return [recall_push(), recalled_elsewhere(), dirty_push(False), + scene_push(scene=4)] + + +def test_after_the_connect_burst_nothing_needs_re_reading(link): + transport, cache = link + for message in the_connect_burst(): + cache.apply_push(message) + stale = [name for name in ("preset", "scene", "dirty") + if cache.needs_read(name)] + assert not stale, ( + f"{stale} would go to the unit on first access, for values the connect " + f"burst already delivered") + + +def test_after_the_connect_burst_reading_costs_nothing(link): + """The assertion that cannot be satisfied by looking at a flag.""" + transport, cache = link + for message in the_connect_burst(): + cache.apply_push(message) + before = sum(transport.reads.values()) + assert cache.value("preset", "preset").name == "Structural Fixture" + assert cache.value("scene", "selected_scene") == 4 + assert cache.value("dirty", "is_dirty") is False + assert sum(transport.reads.values()) == before, ( + "the model asked the unit for something the burst had already given it") + + +def test_a_partial_push_does_not_answer_an_entry(link): + """The condition that keeps the rule honest. `identity` keeps two fields, + and a Version carrying one of them is not the unit's whole answer - so it + must not clear a mark.""" + transport, cache = link + cache.mark_for_reread("identity", "this test") + cache.apply_push(version_reply(app_fw_version="d14e")) + assert cache.needs_read("identity") + + +def test_a_grid_push_never_answers_the_entry_it_marks(link): + """The trap in the rule, and the reason it compares against the ENTRY's + field set rather than the plan's. A Grid plan keeps nothing, so "carries + every field this plan keeps" is vacuously true of it - and a Grid push would + clear the very mark it had just set.""" + transport, cache = link + cache.apply_push(grid_push()) + assert cache.needs_read("preset") + cache.apply_push(grid_push()) + assert cache.needs_read("preset") + + +def test_a_push_that_names_something_unkept_does_not_answer_the_entry(link): + """Complete in its known fields and still not the whole story: a field + number the schema has never heard of means something changed that we cannot + see, so this cannot be the unit's whole answer.""" + transport, cache = link + cache.mark_for_reread("dirty", "this test") + cache.apply_push(with_an_unknown_field(dirty_push(True))) + assert cache.needs_read("dirty") + + +def test_a_recall_leaves_the_dirty_flag_needing_a_read(link): + """The measured case that makes a change of loaded slot reset `dirty`: a + recall pushes no PresetDirty, so nothing answers this entry and it has to + ask. + + The burst runs first, because it always does - the recall has to be a change + of slot, and there is no such thing as a connection where the first + SetlistPosition anyone sees is a recall. + """ + transport, cache = link + for message in the_connect_burst(): + cache.apply_push(message) + cache.apply_push(dirty_push(True)) + assert not cache.needs_read("dirty") + # the measured order of a real recall, to a different slot + for message in [grid_push(), recall_push(), scene_push(scene=0), + recalled_elsewhere(position=17)]: + cache.apply_push(message) + assert cache.needs_read("dirty"), ( + "a recall discards unsaved edits and says nothing about it, so the " + "model would go on reporting changes that no longer exist") + + +def test_a_recall_in_the_order_the_unit_sends_it_leaves_the_preset_trusted(link): + """The measured order is Grid FIRST, then RecallPreset about 90 ms later. + + Replayed that way round, the RecallPreset carries the whole entry and clears + the mark the Grid pushes set - so a recall does NOT leave the preset needing + a read, which is the desirable behaviour and the whole reason `reason` is + kept. An earlier version of this test applied the two backwards and asserted + the opposite, which the real ordering contradicts. + """ + transport, cache = link + for message in the_connect_burst(): + cache.apply_push(message) + for message in [grid_push(), grid_push(), recall_push()]: + cache.apply_push(message) + assert not cache.needs_read("preset") + + +def test_an_edit_on_the_unit_does_leave_the_preset_needing_a_read(link): + """The case the Grid pushes exist for, with no RecallPreset behind them.""" + transport, cache = link + for message in the_connect_burst(): + cache.apply_push(message) + assert not cache.needs_read("preset") + cache.apply_push(grid_push()) + assert cache.needs_read("preset") diff --git a/tests/test_translation.py b/tests/test_translation.py index 3c1391d..16e0629 100644 --- a/tests/test_translation.py +++ b/tests/test_translation.py @@ -24,6 +24,7 @@ import pyquadcortex from pyquadcortex import protocol from pyquadcortex.device import translate +from pyquadcortex.protocol.proto import Preset_pb2 as preset_pb # -- rows: 1-4 on screen, 0-3 on the wire ------------------------------------ @@ -624,12 +625,51 @@ def test_a_hold_timing_index_that_is_not_a_whole_number_is_refused(index): # directory up - which is where somebody would put it after reading a failure # message that named a directory. -BOUNDARY = pathlib.Path(translate.__file__).resolve() +#: The boundary is a PACKAGE, so the exemption below covers a directory rather +#: than a file. That is a bigger hole and it is why `BOUNDARY_MODULES` exists. +BOUNDARY = pathlib.Path(translate.__file__).resolve().parent +BOUNDARY_SOURCES = sorted(BOUNDARY.rglob("*.py")) PACKAGE_ROOT = pathlib.Path(pyquadcortex.__file__).resolve().parent PROTOCOL_ROOT = pathlib.Path(protocol.__file__).resolve().parent MODEL_SOURCES = sorted(p for p in PACKAGE_ROOT.rglob("*.py") if not p.is_relative_to(PROTOCOL_ROOT)) -OTHER_MODEL_SOURCES = [p for p in MODEL_SOURCES if p != BOUNDARY] +OTHER_MODEL_SOURCES = [p for p in MODEL_SOURCES + if not p.is_relative_to(BOUNDARY)] + +#: The boundary package's modules, by name. Everything in that directory is +#: exempt from the two checks at the bottom of this file, so without this list +#: the exemption is a hole shaped like a directory: somebody adds +#: `translate/whatever.py`, puts the arithmetic in it, and the scan skips the +#: file for the same reason it skips the real converters. Naming them means a +#: new module has to come through here, with a reason, in the same commit. +BOUNDARY_MODULES = frozenset({ + "__init__", # re-exports the whole public surface + "guards", # the shared type checks + "coordinates", # rows 1-4 and slots 1-8 + "letters", # scene and footswitch letters + "addresses", # "28C" and its linear position + "units", # dB, Hz, bpm, milliseconds + "grid", # a whole wire preset, renumbered for the screen +}) + + +def test_the_boundary_package_holds_only_the_modules_it_names(): + # Keyed on the path within the package, not on the filename. `rglob` is + # recursive, so a stem-keyed check let `translate/legacy/grid.py` pass as + # "grid" - and everything under the boundary is exempt from the arithmetic + # scan, so that was the directory-shaped hole this test exists to close, + # still open one level down. + found = {str(p.relative_to(BOUNDARY).with_suffix("")) for p in BOUNDARY_SOURCES} + added = sorted(found - BOUNDARY_MODULES) + gone = sorted(BOUNDARY_MODULES - found) + assert not added, ( + f"{added} is inside the translation boundary but is not named in " + f"BOUNDARY_MODULES. Every file in that directory TREE is exempt from " + f"the index-arithmetic scan, so a module added quietly is a way round " + f"the whole rule. Add it here with a reason, or put it outside.") + assert not gone, ( + f"BOUNDARY_MODULES names {gone}, which is not in the package - so this " + f"list is guarding a file that does not exist") #: The boundary's own coordinate tables. It publishes them, so a model module @@ -783,6 +823,12 @@ def _ord_of_a(node) -> bool: "blocks", "Block", "stomp_assignments", "StompAssignment", "free_rows", "row_status", "RowStatus", "input_chain_rows", "splits", "Split", + # `bypass_state` is here for what it HANDS OVER, not for what it takes: + # `BypassState.scenes` is an eight-tuple keyed by the WIRE's scene index, so + # a model module reading `scenes[1]` for scene B has done the conversion + # this boundary exists to own - with no `- 1` anywhere in sight. That it + # also TAKES a wire row and column is the other half. + "bypass_state", "BypassState", } #: Deliberately NOT here, having been considered: `beats` (already keyed by the #: 1-based BEAT the screen shows, so nothing is left to convert), `param_options` @@ -790,6 +836,85 @@ def _ord_of_a(node) -> bool: #: for a log line). Listing those would make the check fire on model code that #: has no conversion to do, which teaches people to work around it. +#: Protocol-layer names the BOUNDARY uses that are not conversions, so every +#: model module may use them too. +#: +#: This exists because the derived check below holds the boundary to "everything +#: you reach for is a conversion, because converting is all you do". That was +#: true while the boundary converted scalars. It stopped being true when the +#: boundary started reading whole presets, because reading one needs a presence +#: check and the port enums, and neither carries a coordinate or a raw scale. +#: Without this set the check would force `field_present` onto the ban list - +#: and the root `CLAUDE.md` REQUIRES every model property that reads a device +#: field to call it, so that is a rule the codebase cannot have. +#: +#: The criterion is the one `PROTOCOL_CONVERSIONS` uses: what does the name HAND +#: OVER? A row, a slot, a scene index, a footswitch index or a raw scale belongs +#: above. A bool, a port id or a name does not. +#: +#: Keep it short and keep a reason on every entry. A real conversion parked here +#: instead of above is the one way this pair of lists can rot, and no test can +#: catch that - only a reviewer reading the reason can. +PROTOCOL_NON_CONVERSIONS = { + # Hands over a bool about a field's presence. Every model property that + # reads a device field is required to call it, so it could never be banned. + "field_present", + # Port ids, not coordinates. `row.output.destination` IS one of these, and + # a port number is not a row, a slot, a scene or a footswitch. + "Input", "Output", + # Three members of `Output`, which the scan sees separately. Their NAMES + # carry a row number but the values do not - they hand over 16, 17 and 18 - + # and `translate.routes_to_a_row` deliberately refuses to report which row + # they feed, because that reading is obvious rather than confirmed. + "NEXT_ROW_3", "NEXT_ROW_4", "NEXT_ROW_3_4", +} + + +def test_a_module_hidden_in_a_subdirectory_is_caught_too(): + """The check above is only as good as the key it compares on. + + A stem-keyed version passed `translate/legacy/grid.py` as "grid", which is + the exemption's own shape used against it. Proved rather than asserted, + because the failure is silent: the arithmetic scan skips the file for + exactly the reason it skips the real converters. + """ + nested = BOUNDARY / "legacy" / "grid.py" + nested.parent.mkdir(parents=True, exist_ok=True) + nested.write_text("X = 1 - 1\n") + try: + found = {str(p.relative_to(BOUNDARY).with_suffix("")) + for p in sorted(BOUNDARY.rglob("*.py"))} + assert not found <= BOUNDARY_MODULES, ( + "a module one directory down is invisible to the module list, so " + "the arithmetic scan can be escaped by putting the conversion in " + "translate/anything/") + finally: + nested.unlink() + nested.parent.rmdir() + + +def test_the_two_protocol_lists_do_not_overlap(): + """A name on both lists is banned and permitted at once, and which one wins + is whichever check runs. That is worse than either answer.""" + both = sorted(PROTOCOL_CONVERSIONS & PROTOCOL_NON_CONVERSIONS) + assert not both, ( + f"{both} is on the ban list AND on the not-a-conversion list. Decide " + f"which it is: does it hand over a coordinate or a raw scale?") + + +def test_every_non_conversion_is_a_real_protocol_name(): + """Same reason the ban list has this check: a misspelling here silently + widens what the boundary is allowed to reach for.""" + missing = object() + unresolved = sorted( + name for name in PROTOCOL_NON_CONVERSIONS + if getattr(protocol, name, missing) is missing + and getattr(protocol.Output, name, missing) is missing + ) + assert not unresolved, ( + f"{unresolved} is on the not-a-conversion list but is not a name the " + f"protocol layer publishes, so it excuses nothing") + def _protocol_aliases(tree: ast.AST) -> set: """The local names that MEAN the protocol layer in `tree`. @@ -881,7 +1006,7 @@ def test_the_scan_covers_every_module_that_is_not_the_protocol_layer(): is covered the day it is created rather than the day somebody remembers to add it here. """ - assert BOUNDARY in MODEL_SOURCES + assert set(BOUNDARY_SOURCES) <= set(MODEL_SOURCES) assert len(OTHER_MODEL_SOURCES) >= 2 walked = { pathlib.Path(importlib.import_module(info.name).__file__).resolve() @@ -919,21 +1044,36 @@ def test_the_allowlist_covers_everything_the_boundary_delegates_to(): nothing says a word. That is not hypothetical: PR #22 added `tempo_bpm` and `bpm_to_tempo`, and they sat unlisted until this branch put them in by hand. - Derived rather than listed, so it cannot rot the same way: whatever - `translate.py` reaches into the protocol layer for is a conversion by - definition, because converting is all that module does. + Derived rather than listed, so it cannot rot the same way: a name the + boundary reaches for has to be ACCOUNTED FOR, in one list or the other, + before this passes. + + It used to be stronger than that, and the weakening is worth stating. + The rule was "whatever the boundary reaches for IS a conversion, because + converting is all it does", with no second list at all. That held while the + boundary converted scalars. Reading a whole preset broke it: `field_present` + and the port enums are neither conversions nor bannable, so the choice was + a second list or a false rule. What is left is still the direction that + rots - a new conversion cannot arrive silently - but the reviewer now has to + check WHICH list a new name went into. `PROTOCOL_NON_CONVERSIONS` carries a + reason per entry for exactly that reading. """ - reached = _protocol_names_reached(ast.parse(BOUNDARY.read_text())) + reached = set() + for source in BOUNDARY_SOURCES: + reached |= _protocol_names_reached(ast.parse(source.read_text())) assert reached, "the boundary reaches for nothing - this check is vacuous" - unlisted = sorted(reached - PROTOCOL_CONVERSIONS) + unlisted = sorted(reached - PROTOCOL_CONVERSIONS - PROTOCOL_NON_CONVERSIONS) assert not unlisted, ( - f"{BOUNDARY.name} delegates to the protocol layer's {unlisted}, which " - f"is not in PROTOCOL_CONVERSIONS - so every other module in the package " - f"may call it directly and this suite will not notice") + f"the translation boundary delegates to the protocol layer's " + f"{unlisted}, which is on neither list. If it hands over a row, a slot, " + f"a scene or footswitch index, or a raw scale, put it in " + f"PROTOCOL_CONVERSIONS so no other module may call it. If it does not, " + f"put it in PROTOCOL_NON_CONVERSIONS with the reason.") #: The four functions the exclusion exists for. Named, because "somewhere in -#: translate.py" is not the thing being protected. +#: the translate package" is not the thing being protected - and a package is a +#: vaguer somewhere than a file was. COORDINATE_CONVERTERS = ("row_to_wire", "row_from_wire", "slot_to_wire", "slot_from_wire") @@ -950,19 +1090,21 @@ def test_the_boundary_itself_still_does_the_arithmetic(): `ROWS.index(row)`, arithmetic-free, and this backstop would have stayed green on that one line while the "nowhere else" check stayed silent too. """ - functions = {node.name: node - for node in ast.walk(ast.parse(BOUNDARY.read_text())) - if isinstance(node, ast.FunctionDef)} + functions = {} + for source in BOUNDARY_SOURCES: + for node in ast.walk(ast.parse(source.read_text())): + if isinstance(node, ast.FunctionDef): + functions[node.name] = node gone = [name for name in COORDINATE_CONVERTERS if name not in functions] assert not gone, ( - f"{BOUNDARY.name} no longer defines {gone} - this test names the " - f"converters it is protecting, so a rename has to come through here") + f"the translation boundary no longer defines {gone} - this test names " + f"the converters it is protecting, so a rename has to come through here") silent = [name for name in COORDINATE_CONVERTERS if not _index_arithmetic(functions[name])] assert not silent, ( - f"{silent} in {BOUNDARY.name} do no index arithmetic. If the conversion " - f"moved somewhere else, the 'nowhere else' check below is now passing " - f"because nothing anywhere converts") + f"{silent} do no index arithmetic. If the conversion moved somewhere " + f"else, the 'nowhere else' check below is now passing because nothing " + f"anywhere converts") @pytest.mark.parametrize("source", OTHER_MODEL_SOURCES, ids=lambda p: p.name) @@ -1117,3 +1259,222 @@ def test_the_letter_types_and_the_address_are_public(): assert pyquadcortex.SceneLetter is translate.SceneLetter assert pyquadcortex.PresetAddress is translate.PresetAddress assert not hasattr(pyquadcortex, "row_to_wire") + + +# -- a wire preset, read in the numbers the screen shows ---------------------- +# +# The conversions above are checked one value at a time. These read a REAL +# preset payload, because the mistakes that survive a unit-value test are the +# ones about shape: every row reports eight slots whether or not they hold +# anything, a branch's columns are not on the splitter block, and the bypass +# table is keyed by wire scene index. + +PRESETS = pathlib.Path(__file__).parent / "fixtures" / "presets" + + +def _preset_fixture(name): + payload = preset_pb.BinaryPreset() + payload.ParseFromString((PRESETS / name).read_bytes()) + return payload + + +@pytest.fixture +def structural(): + return _preset_fixture("structural_preset.bin") + + +@pytest.fixture +def split(): + return _preset_fixture("split_preset.bin") + + +def test_placed_blocks_are_numbered_the_way_the_screen_numbers_them(structural): + """The fixture's first block is wire row 0 column 0, which is row 1 slot 1.""" + placed = translate.placed_blocks(structural) + assert placed[0].row == 1 and placed[0].slot == 1 + assert {b.row for b in placed} == {1, 3}, ( + "the fixture holds blocks on wire rows 0 and 2") + assert max(b.slot for b in placed) == 8, "wire column 7 is slot 8" + assert min(b.slot for b in placed) == 1 + + +def test_every_placed_block_agrees_with_the_protocol_layer(structural): + """Same cells, two vocabularies. The only difference must be the numbering.""" + wire = protocol.blocks(structural) + screen = translate.placed_blocks(structural) + assert len(screen) == len(wire) + for w, s in zip(wire, screen): + assert s.row == translate.row_from_wire(w.row) + assert s.slot == translate.slot_from_wire(w.column) + assert s.device_id == w.model_id + + +def test_only_rows_1_and_3_can_start_a_branch(): + assert translate.SPLITTABLE_ROWS == (1, 3) + + +def test_path_b_is_the_row_below(): + assert translate.path_b_of(1) == 2 + assert translate.path_b_of(3) == 4 + + +@pytest.mark.parametrize("row", [2, 4]) +def test_a_row_that_cannot_branch_has_no_path_b(row): + with pytest.raises(ValueError, match="1 or 3"): + translate.path_b_of(row) + + +def test_a_preset_with_no_branch_reports_none(structural): + assert translate.branches(structural) == () + + +def test_the_split_fixture_still_holds_the_two_shapes_it_is_for(split): + """The fixture is DERIVED (see make_split_preset.py). If it were ever + regenerated from a serial source, every branch test below would pass by + reading nothing, so the fixture's own shape is asserted first.""" + wire = protocol.splits(split) + assert len(wire) == 2, "one branch that rejoins, one that does not" + assert [s.rejoins for s in wire] == [False, True] + + +def test_a_branch_is_numbered_the_way_the_screen_numbers_it(split): + branch = translate.branches(split)[0] + assert branch.row == 1, "wire row 0 is screen row 1" + assert branch.at == 3, "wire column 2 is slot 3" + assert branch.path_b == 2 + + +def test_a_branch_that_never_rejoins_says_so(split): + """`mix` is -1 for a lane that never recombines, and -1 is a real column + number away from being read as one. It has to come back as None.""" + assert translate.branches(split)[0].rejoins_at is None + + +def test_a_branch_that_rejoins_reports_where(split): + branch = translate.branches(split)[1] + assert branch.row == 3 + assert branch.at == 4, "wire column 3 is slot 4" + assert branch.rejoins_at == 5, "wire column 4 is slot 5" + assert branch.path_b == 4 + + +def test_the_splitter_and_the_mixer_are_not_the_same_slot(split): + """A reader that returned `at` for `rejoins_at` would pass a test whose + fixture branched and rejoined in the same column.""" + branch = translate.branches(split)[1] + assert branch.at != branch.rejoins_at + + +def test_every_branch_agrees_with_the_protocol_layer(split): + wire = protocol.splits(split) + screen = translate.branches(split) + assert len(screen) == len(wire) + for w, s in zip(wire, screen): + assert s.row == translate.row_from_wire(w.row) + assert s.at == translate.slot_from_wire(w.split_column) + assert s.path_b == translate.row_from_wire(w.lane_row) + if w.rejoins: + assert s.rejoins_at == translate.slot_from_wire(w.mix_column) + else: + assert s.rejoins_at is None + + +def test_a_wire_column_no_row_has_is_refused_when_a_preset_is_read(structural): + """The renumbering VALIDATES rather than just adding one. A preset carrying + column 99 is a preset something wrote wrongly, and reporting slot 100 would + pass it on as though the screen could show it.""" + structural.chains[0].models[0].column = 99 + with pytest.raises(ValueError, match="0 to 7"): + translate.placed_blocks(structural) + + +def test_the_row_input_is_the_port_the_unit_reports(structural): + assert translate.row_input(structural, 1) == protocol.Input.INPUT_1 + + +def test_an_output_that_feeds_another_row_is_recognised(structural): + destination = translate.row_output(structural, 1) + assert destination == protocol.Output.NEXT_ROW_3 + assert translate.routes_to_a_row(destination) + + +def test_a_real_destination_is_not_a_row(structural): + assert not translate.routes_to_a_row(translate.row_output(structural, 3)) + assert not translate.routes_to_a_row(protocol.Output.XLR_1_2) + + +def test_a_row_the_screen_does_not_show_has_no_chain(structural): + with pytest.raises(ValueError, match="1 to 4"): + translate.row_input(structural, 5) + + +def test_bypass_reads_through_the_scene_letter(structural): + """The wire keys the eight bypass slots by scene INDEX; the model asks by + letter, and this is the only place that mapping happens.""" + wire = protocol.bypass_state(structural, 0, 0) + for letter in LETTERS: + assert translate.block_bypassed(structural, 1, 1, letter) is \ + wire.scenes[protocol.Scene[letter]] + + +def test_bypass_tells_two_scenes_apart(structural): + """The fixture stores the same flag in all eight, so drive them apart first + - otherwise this test passes on a reader that ignores the scene entirely.""" + cell = structural.bypass[0].colBypass[0] + cell.sceneMode = True + cell.sceneBypass[0].bypass = True + cell.sceneBypass[1].bypass = False + assert translate.block_bypassed(structural, 1, 1, "A") is True + assert translate.block_bypassed(structural, 1, 1, "B") is False + + +def test_bypass_refuses_a_bare_scene_number(structural): + with pytest.raises(TypeError): + translate.block_bypassed(structural, 1, 1, 1) + + +def test_a_scene_name_is_read_by_letter(structural): + assert translate.scene_name(structural, "A") == "Scene A" + assert translate.scene_name(structural, translate.SceneLetter.H) == "Scene H" + + +def test_an_unlabelled_scene_reads_as_no_name(structural): + """The unit stores a single space for "no label" and shows the letter + instead, so `label == ""` does not detect it (protocol.SCENE_UNLABELLED).""" + structural.scene_labels[1] = protocol.SCENE_UNLABELLED + assert translate.scene_name(structural, "B") == "" + + +def test_a_scene_name_refuses_a_bare_number(structural): + with pytest.raises(TypeError): + translate.scene_name(structural, 1) + + +def test_a_scene_letter_does_not_equal_a_footswitch_letter(): + """The module header says a scene letter reaching a footswitch API has to be + a type error. The converters enforced that at their doors; the VALUE TYPES + did not, so `SceneLetter.A == FootswitchLetter.A` was True and a mapping + keyed by one answered to the other - and `preset.stomps` is documented as + exactly such a mapping.""" + assert translate.SceneLetter.A != translate.FootswitchLetter.A + assert translate.FootswitchLetter.E != translate.SceneLetter.E + assert {translate.FootswitchLetter.E: "vibe"}.get(translate.SceneLetter.E) is None + assert translate.SceneLetter.B not in {translate.FootswitchLetter.B} + + +def test_a_letter_still_behaves_like_the_string_it_prints_as(): + """The whole reason these are strings. Telling the two enums apart must not + cost `scenes["B"]`, printing, or keying a plain dict by the letter.""" + for letter in (translate.SceneLetter.E, translate.FootswitchLetter.E): + assert letter == "E" + assert "E" == letter + assert str(letter) == "E" + assert {letter: "x"}["E"] == "x" + assert {"E": "x"}[letter] == "x" + assert letter in ("E", "F") + + +def test_a_letter_still_equals_itself(): + assert translate.SceneLetter.A == translate.SceneLetter.A + assert translate.SceneLetter("A") == translate.SceneLetter.A + assert len({translate.SceneLetter.A, translate.SceneLetter("A")}) == 1 diff --git a/tests/waiting.py b/tests/waiting.py new file mode 100644 index 0000000..be5dbf1 --- /dev/null +++ b/tests/waiting.py @@ -0,0 +1,50 @@ +"""Waiting on the model's event thread, for the tests that need to. + +The model delivers events on a thread of its own, so a test that asserted +straight after publishing would race it and pass or fail by timing. + +This is a plain module rather than fixtures in ``conftest.py`` on purpose. There +are two conftest files in this suite - the rootdir's and ``tests/hardware/``'s - +and ``from conftest import ...`` resolves to whichever pytest inserted last, +which is the hardware one on a full-tree run. That failed loudly here, and it +would have failed quietly if the two files had ever held a same-named helper. +""" + +import time + +#: How long a test waits for an event before giving up. Generous, because it is +#: only ever reached on a failure: a working stream delivers in microseconds. +PATIENCE = 2.0 + +#: How long a test waits to be sure NOTHING is coming. A ceiling on plausible +#: delivery rather than a guess at it - see :func:`stays_quiet`. +QUIET = 0.1 + + +def wait_for(box, count, seconds=PATIENCE): + """Block until ``box`` holds ``count`` items, or fail saying how many came. + + Raises ``AssertionError`` rather than returning a flag: every caller wants + the test to stop here, and one that forgot to check the flag would go on to + assert against a list that was still filling. + """ + deadline = time.monotonic() + seconds + while time.monotonic() < deadline: + if len(box) >= count: + return + time.sleep(0.005) + raise AssertionError( + f"waited {seconds}s for {count} event(s) and {len(box)} arrived") + + +def stays_quiet(box, seconds=QUIET): + """Give the delivery thread time to publish, then report what it published. + + The counterpart to :func:`wait_for`, for tests asserting that nothing is + published. Those cannot wait on a condition - there is no condition - so + they have to wait out a plausible delivery instead. A test that skipped the + wait would pass against a stream that publishes everything, simply by asking + before the thread got there. + """ + time.sleep(seconds) + return list(box)