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

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
7 changes: 7 additions & 0 deletions .github/workflows/ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -39,3 +39,10 @@ jobs:
pip install build twine
python -m build
twine check dist/*
# twine check reads metadata, not the file list. The generated protobuf
# bindings are what lets `pip install` work with no protoc toolchain
# (ADR-0001), so a build rule that dropped them would produce a wheel that
# installs cleanly and fails on the first import. Nothing else in the repo
# looks inside a built artifact.
- name: The artifacts carry the bindings, the CLI and both namespaces
run: python scripts/check_artifacts.py dist
2 changes: 1 addition & 1 deletion .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -46,6 +46,6 @@ desktop.ini
.claude/
.aider*

# NOTE: pyquadcortex/proto/*_pb2.py are generated but INTENTIONALLY committed,
# NOTE: pyquadcortex/protocol/proto/*_pb2.py are generated but INTENTIONALLY committed,
# so that installing the package needs no protoc toolchain. Do not ignore them.
# See docs/architecture.md.
10 changes: 7 additions & 3 deletions CLAUDE.md
Original file line number Diff line number Diff line change
Expand Up @@ -8,9 +8,13 @@ Read `docs/STEERING.md` before non-trivial work (new operations, transport or fr
## Conventions

- 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`.
- `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, and gets a test proving the module imports cleanly without hidapi.
- Never gitignore or delete `pyquadcortex/proto/*_pb2.py` - the generated bindings are committed on purpose (ADR-0001). Regenerate only via `scripts/compile_protos.sh`, and bump the `protobuf` pin in `pyproject.toml` in the same commit as regenerated bindings.
- New operations follow `docs/architecture.md` "How to add a new operation": register the type, add a thin client method (no HID, no bytes, no sleeps in `client.py`), add an offline test asserting the exact wire shape, then verify on hardware and update the coverage table in `docs/protocol.md`.
- Two namespaces, one package (ADR-0006): `pyquadcortex` is the model of the unit, `pyquadcortex.protocol` is the message-level API. The model imports the protocol layer; nothing under `pyquadcortex/protocol/` may import from `pyquadcortex/model/`.
- 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. Never cache a reply that came back incomplete - a retry has to be able to recover.
- 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.
- `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. Read the gencode version in the regenerated diff before committing it: an older `grpcio-tools` in the venv silently emits older gencode, which still imports and quietly walks the pin backwards. CI's `build` job runs `scripts/check_artifacts.py`, which proves the bindings are inside the wheel and the sdist.
- New operations follow `docs/architecture.md` "How to add a new operation": register the type, add a thin client method (no HID, no bytes, no sleeps in `protocol/client.py`), add an offline test asserting the exact wire shape, then verify on hardware and update the coverage table in `docs/protocol.md`.
- Grid mutations use the row/column-keyed pattern (`set_param` / `set_bypass`) - never extend the wholesale `write_preset` path.
- Docstrings state their evidence: confirmed on hardware vs inferred from the schema. When you verify something on hardware, record it (docstring + coverage table) in the same change.
- Code in the RX path preserves "the RX thread never dies": wrap every decode, skip unknown types at debug level, reset the reassembly buffer on anything malformed.
Expand Down
66 changes: 60 additions & 6 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -20,6 +20,14 @@ script. What is and is not covered is listed feature by feature in
The library imports as `pyquadcortex`; a command-line tool named `qcctl` comes
with it.

> **Upgrading from 0.40.0 or earlier?** In the next release the message-level API
> moves to `pyquadcortex.protocol`. Change `from pyquadcortex import X` to
> `from pyquadcortex.protocol import X`, and `pyquadcortex.connect()` to
> `protocol.connect()`. Submodule paths take the same step, so
> `pyquadcortex.proto`, `.client`, `.enums` and `.session` become
> `pyquadcortex.protocol.proto` and the rest. Nothing else about the API
> changed. See [Two ways in](#two-ways-in) below.

> Unofficial and not affiliated with, endorsed by, or supported by Neural DSP
> Technologies. "Quad Cortex" and "Neural DSP" are trademarks of their owner and
> are used here only to describe what this software talks to.
Expand Down Expand Up @@ -60,15 +68,61 @@ Python 3.11 or newer.
it is running nothing else can talk to the device. (Wi-Fi can stay on, it makes
no difference. The Quad Cortex just has to be plugged in over USB.)

## Two ways in

The package has two namespaces, and you can use either one or both.

**`pyquadcortex.protocol`** is the message-level API: one Python call per Quad
Cortex protocol message. It covers every message this library has confirmed on
hardware, it is what everything below is written against, and it is what this
library shipped as through 0.40.0. It moves here in the next release, unchanged,
and its submodules move with it (`pyquadcortex.proto` becomes
`pyquadcortex.protocol.proto`, and so on). What the unit can do that this library
still cannot is listed in
[docs/manual-coverage.md](https://github.com/stokes-audio/pyquadcortex/blob/main/docs/manual-coverage.md).

```python
from pyquadcortex import protocol

with protocol.connect() as qc:
qc.switch_scene(1)
```

**`pyquadcortex` itself** is the model of the unit: objects that look and behave
the way the Quad Cortex does, so you write what you mean instead of holding
protocol facts in your head. It is being built now, and today it gives you the
unit's identity and not much else.

```python
import pyquadcortex

with pyquadcortex.connect() as device:
print(device.firmware, device.serial)
```

Use the protocol layer for anything the model does not cover yet. To mix the two
in one script, wrap a connection you already have:

```python
from pyquadcortex import Device, protocol

with protocol.connect() as qc:
device = Device.from_client(qc)
```

Where the model is going is in
[docs/domain-model.md](https://github.com/stokes-audio/pyquadcortex/blob/main/docs/domain-model.md).
Everything below on this page is the protocol layer.

## Quickstart

Everything here uses the factory library, so it works on any unit.

```python
import pyquadcortex
from pyquadcortex import Input, Instrument, Scene, Setlist
from pyquadcortex import protocol
from pyquadcortex.protocol import Input, Instrument, Scene, Setlist

with pyquadcortex.connect() as qc:
with protocol.connect() as qc:
# What are we talking to?
print(qc.version().app_fw_version)

Expand All @@ -90,9 +144,9 @@ with pyquadcortex.connect() as qc:
instrument=Instrument.BASS)
```

`connect()` finds the device, opens it, and completes the handshake the device
requires, so what you get back is ready to use. As a context manager it also
releases the device when the block ends; otherwise call `qc.close()`.
`protocol.connect()` finds the device, opens it, and completes the handshake the
device requires, so what you get back is ready to use. As a context manager it
also releases the device when the block ends; otherwise call `qc.close()`.

Closing tells the device the client is leaving, which is what Cortex Control does on
quit. If you supplied your own transport and so own teardown yourself, send it with
Expand Down
96 changes: 94 additions & 2 deletions changelog.md
Original file line number Diff line number Diff line change
Expand Up @@ -5,8 +5,10 @@ installing the package. The git history has the detail and the reasoning; this
file answers the narrower question "I upgraded, what is different for me?".

Versions follow the usual 0.x convention: the minor number moves for new
capability, the patch number for fixes. Anything may still change while the
major number is 0.
capability and the patch number for fixes. While the major number is 0, a
breaking change moves the **minor** too - it does not move the major, because
that number is reserved for the 1.0.0 conditions below. Anything may still
change while the major number is 0.

That is a deliberate signal, not neglect. Everything here is verified against ONE
unit on one firmware, protocol facts are still being corrected at a live rate, and
Expand All @@ -16,6 +18,96 @@ has landed or been deliberately dropped, the library has been verified on a seco
unit or firmware, and the protocol record has gone a sustained stretch without a
correction.

## Unreleased
Comment thread
jonathanstokes marked this conversation as resolved.

### BREAKING: the protocol API moved to `pyquadcortex.protocol`

**Change one import line.** `from pyquadcortex import X` becomes
`from pyquadcortex.protocol import X`, and `pyquadcortex.connect()` becomes
`protocol.connect()`:

```python
from pyquadcortex import protocol

with protocol.connect() as qc: # was: pyquadcortex.connect()
qc.switch_scene(1)
```

That is the whole migration for the names the package exported. Every one of them
is reachable under `pyquadcortex.protocol`, with the same behaviour - same
classes, same methods, same arguments, same results. Nothing about the protocol
API changed except where it is imported from. A test enumerates the old export
list and proves it.

**Submodule paths took the same step**, and no test can prove that part for you
because those were never top-level exports. If you import a submodule directly,
add `protocol.` to it:

| before | after |
|---|---|
| `pyquadcortex.proto` | `pyquadcortex.protocol.proto` |
| `pyquadcortex.client` | `pyquadcortex.protocol.client` |
| `pyquadcortex.enums` | `pyquadcortex.protocol.enums` |
| `pyquadcortex.session` | `pyquadcortex.protocol.session` |

`pyquadcortex.proto` is the one to check for: decoding a capture with the shipped
protobuf bindings is the documented way to do it, and the line in
`docs/capture.md` used to read `from pyquadcortex.proto import
ProductionAutomation_pb2 as pa`.

`qcctl` is unchanged. If you installed the package in editable mode before this
change, reinstall it so the console script points at the new module path.

**Why now.** `import pyquadcortex` should hand you the Quad Cortex, not the wire.
The model of the unit is being built, and it takes the top-level name; the protocol
layer keeps everything it had, one import deeper. This library is deliberately 0.x
with roughly no users, so the break is as cheap today as it will ever be. The
decision is ADR-0006.

### `pyquadcortex.connect()` now returns a `Device`

The model's front door. Today it tells you what you are connected to and not much
else:

```python
import pyquadcortex

with pyquadcortex.connect() as device:
print(device.firmware, device.serial)
```

Presets, scenes, the grid and the rest are being added story by story - see
[docs/domain-model.md](https://github.com/stokes-audio/pyquadcortex/blob/main/docs/domain-model.md)
for where it is going. Nothing is stubbed out to look finished, so if it is not
there yet, use the protocol layer.

To use both layers in one script, wrap a connection you already have with
`Device.from_client(qc)`. It does not take ownership: closing the `Device` leaves
your connection open.

### Withdrawn: the Tempo menu's MODE is "not on the wire"

The 0.23.0 entry below records, under **Settled**, that the Tempo menu's MODE
(global vs per-preset) is not on the wire. **That claim is withdrawn.** It was
carried in the documentation from 0.33.0 through 0.40.0 and is wider than the
evidence behind it.

What was actually measured is that MODE is never BROADCAST. Three independent
tests watched for a broadcast when the switch was changed and saw nothing, and
the instrument in the later two is worth trusting - 70 of the device's 72 message
types decoded, with a liveness heartbeat proving the link was up. But all three
listened, and none of them asked. **A control the device never announces may
still answer a READ.** Nothing has tried one.

So MODE is an open protocol investigation rather than a settled dead end. Nothing
in the library changes: there was no MODE surface before and there is none now.
The reason it is worth an entry is that the withdrawn claim is what the coverage
audit and the model design were both written against. The correction is in
`docs/protocol.md`, `docs/manual-coverage.md`, `docs/capture.md` and
`docs/domain-model.md`, and the decision it forced - that the model shows a
control it cannot yet drive and refuses it, rather than omitting it or guessing -
is ADR-0007.

## 0.40.0 - 2026-08-10

### The lane/mixer level span is -40..+12 dB, not -100..+30
Expand Down
2 changes: 1 addition & 1 deletion contributing.md
Original file line number Diff line number Diff line change
Expand Up @@ -46,7 +46,7 @@ python3 -m venv .venv && . .venv/bin/activate && pip install -e ".[dev]"

### The protobuf bindings

The generated `pyquadcortex/proto/*_pb2.py` bindings are **committed to the
The generated `pyquadcortex/protocol/proto/*_pb2.py` bindings are **committed to the
repository on purpose** - that is what lets `pip install` work without a protoc
toolchain. Please do not add them to `.gitignore`.

Expand Down
17 changes: 17 additions & 0 deletions docs/ADR.md
Original file line number Diff line number Diff line change
Expand Up @@ -73,3 +73,20 @@ Records are append-only once `Decided` and built upon: a shipped decision is nev
- **Open Questions:** None.
- **Rationale:** The model becomes the documented front door the moment it exists, with no release where `connect()` is ambiguous. The protocol layer loses nothing: same API, same support, one import deeper.
- **Consequences:** Refines ADR-0004's "additive namespace" consequence: the model is still additive code-wise and the protocol API is still public and unchanged, but import paths flip at M1 - existing 0.x scripts update one import line. The flip and its changelog/readme messaging land in the M1 Epic. The Intent Brief's "Additive, not breaking" requirement is amended to match (owner decision, 2026-08-05).

## ADR-0007: The model may represent a control whose wire path is still open

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This rule has no code, no exception type and no test yet, which is consistent but leaves the first user of it with nothing to inherit.

I am not calling this a defect. The ADR says the exception shape stays open until M3, the pull request says no tempo surface ships here, and pyquadcortex/model/ contains no raise at all, so the three statements agree.

The thing worth noticing is that the four-test safety case in this change does not touch ADR-0007 at all, so nothing mechanical will stop the first M3 surface from guessing. Two decisions are much cheaper to pin now, while the reasoning is fresh, than to retrofit later:

The refusal wants its own exported exception. The pattern already exists here in BlockRefused at pyquadcortex/protocol/client.py:136, but reusing that one would be wrong: BlockRefused means the device said no, and this means we have not found the message, and merging them makes both impossible to catch separately. NotImplementedError is also a poor fit, since callers cannot tell it from a genuine unfinished-method bug and tooling reads it that way too.

There also needs to be a way to ask without touching. With a property that raises, "read it if you can, skip it if you cannot" forces a try/except around every attribute, and any generic state dump or inspector blows up on attribute access. The comment on Device.__repr__ shows this concern is already understood one level up; a raising property reintroduces it one level down. Pairing the exception with a supported predicate avoids that.

When the first refusing property does land, the test worth writing is that the refusal happens with a client whose send and request fail the test if called at all, so refusing-before-writing is pinned rather than assumed.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Agreed it is not a defect, and I have not turned it into code. Not resolving.

ADR-0007's Open Questions already parks the exception shape until the first such
control ships, M3 at the earliest, so the three statements you checked still
agree. Settling it now means designing an M3 API with no consumer in front of
us. The record is also Decided, so a new constraint would have to arrive as a
resolution of that open question or as a new record, not as an edit.

Your two constraints are the useful part and I think both are right. Reusing
BlockRefused would merge "the device said no" with "we have not found the
message", and then neither can be caught on its own. NotImplementedError reads
as an unfinished method to people and to tooling. The supported predicate is
the point I had not considered: a raising property does break a generic state
dump, which is the same concern the __repr__ comment is protecting one level
up.

I did correct one fact in the record while I was in it. It said all three tests
watched the switch "changed and committed", and the third test's action script
has only the toggle - no OK step before the BANK UP that ends the section. Two
of the three committed. ADR-0007 has not shipped, since this same pull request
introduces it, so fixing a fact in it is not rewriting a shipped decision.

Whether to pin the exception and the predicate now is your call.


- **Status:** Decided (2026-08-11)
- **Decision:** When we understand what a control does on the unit but have not found the message that drives it, the model represents the control the way the unit presents it, and refuses the operation it cannot yet perform. It never guesses. This is a different case from design principle 3's "omission over caveat", which covers a control whose behaviour ON THE DEVICE we do not understand; that one is still omitted.
- **Context:** TEMPO MODE is the case that raised this. The unit's Tempo menu has a GLOBAL / PRESET switch. In PRESET mode the tempo and all seven metronome settings are stored in the preset, which is exactly what the wire shows: every preset carries a `TempoControl` block and it is writable (`protocol.md`, "Per-preset tempo, LED and metronome"). Cortex Control offers the same switch, so some route to it exists. What we have not found is that route. Three independent tests watched for a broadcast when the switch is changed, and saw nothing; two of the three also committed the menu, and the third's action script records only the toggle. The design doc recorded all of this as "not on the wire at all". That conclusion is now understood to be too strong. **"Broadcasts nothing" is not "not readable".** A control the device never announces may still answer a READ, or be carried in a message we have not decoded, and no test so far has ruled either out.
- **Options:**
- **(a) Model the switch, refuse what cannot be driven - chosen.** The player sees a switch, so the model has one. Reading it, or writing it, raises with a message saying the wire path is not known yet.
- **(b) Omit tempo from the model entirely.** Throws away a control we do understand - the BPM and the whole metronome are confirmed writable - to avoid one field we do not.
- **(c) Let a tempo write through and work out the scope afterwards.** A guess behind a clean API. The write lands in whichever scope the unit happens to be in, and the caller is told nothing. This is the "the model lies" failure the Intent names, and it is the worst of the three because it looks like it worked.
- **Open Questions:** How the refusal reads in practice - one exception type for "no wire path known", or a per-feature message. Settled when the first such control ships, which is M3 at the earliest.
- **Rationale:** The model's job is to look like the unit. A switch the player can see, missing from the model with no explanation, is its own kind of lie. Refusing is honest in both directions: it tells the caller the control is real and tells them we cannot drive it. It also keeps the door open, because a model with a `mode` on it has somewhere to put the answer the day the wire path turns up. Option (c) is rejected on the project's oldest rule: the device accepts a write it does not understand and says nothing, so a guess and a success are indistinguishable to the caller.
- **Consequences:**
- The design doc's `Tempo` gains `mode`, and the appendix's "Tempo MODE" row changes from a permanent omission to an open protocol investigation. `docs/domain-model.md` §13 says the same.
- Finding the MODE wire path becomes a prerequisite of M3's device-settings Epic, not of M1. **No tempo surface ships at M1**, so nothing in this record is user-visible yet.
- Design principle 3 keeps its meaning and gains a boundary: omission is for behaviour we do not understand, refusal is for behaviour we understand and cannot yet drive. A record that says which one applies is now expected of anything the model leaves out.
- This does not license modelling controls on a hunch. It applies where the unit's behaviour is confirmed and only the message is missing; a control we have not understood on the hardware is still omitted.
Loading
Loading