# Protocol guides One page per protocol. Each explains the bring-up sequence and the shape of the operations, then points at the runnable scripts that already exist under `examples/` in the repository — the guides deliberately do not invent new snippets, because the examples are exercised against real hardware and these pages are not. Before any of them, there is the handle every one of them sits inside: [The device lifecycle](lifecycle.md) covers discovery, the three `open()` selectors, the states a {py:obj}`~pycosmicsdk.Device` moves through, what capabilities you can ask about in advance, and the two calls that end a handle for good. | Guide | Interfaces | Adapters | |---|---|---| | [I2C](i2c.md) | {py:obj}`~pycosmicsdk.I2cController`, {py:obj}`~pycosmicsdk.AsyncI2cController` | Supernova, Pulsar | | [I3C](i3c.md) | {py:obj}`~pycosmicsdk.I3cController`, {py:obj}`~pycosmicsdk.I3cCcc`, {py:obj}`~pycosmicsdk.I3cTarget` + async twins | Supernova only | | [UART](uart.md) | {py:obj}`~pycosmicsdk.Uart`, {py:obj}`~pycosmicsdk.AsyncUart` | Supernova, Pulsar | | [SPI](spi.md) | {py:obj}`~pycosmicsdk.SpiController`, {py:obj}`~pycosmicsdk.AsyncSpiController` | Supernova, Pulsar | | [GPIO](gpio.md) | {py:obj}`~pycosmicsdk.Gpio`, {py:obj}`~pycosmicsdk.AsyncGpio` | Supernova, Pulsar | | [Atira debug probe](debugprobe.md) | {py:obj}`~pycosmicsdk.DebugProbe`, {py:obj}`~pycosmicsdk.AsyncDebugProbe`: target power, SWD debug, SPI and SPI NOR flash, UART bridge | Atira | Two pages cut across the protocols rather than describing one, and both are new: | Guide | What it covers | Adapters | |---|---|---| | [The bulk transfer plane](bulk.md) | The larger payload ceiling on the vendor USB endpoint: which of the six protocol/role cells can reach it, the per-model ceilings, the three `transport=` enums and why I2C's `AUTO` differs, and how to diagnose an unavailable plane | Supernova, Pulsar (I3C bulk: Supernova only) | | [Target mode](target-mode.md) | I2C and SPI acting as a target rather than a controller: taking the bus role and handing it back with `release()`, the shared staging arena, the emulation models, and the metadata-only bus events | Supernova, Pulsar (SPI target needs a rev-C Supernova) | ## Running the examples The scripts under `examples/` talk to a real adapter and are not part of the test suite. Without a source checkout, download them as {download}`pycosmicsdk-examples.zip <../_generated/pycosmicsdk-examples.zip>`, unzip, `pip install pycosmicsdk`, and run them with `python` from inside the unzipped folder. From a source checkout: ```bash uv run python examples/blocking_api/i2c/blocking.py ``` Layout, per `examples/criteria.md`: `examples/blocking_api//` : Blocking {py:obj}`~pycosmicsdk.Device` API. `examples/async_api//` : `asyncio` {py:obj}`~pycosmicsdk.AsyncDevice` API. Most files are twins of a blocking script — same workflow and console output — with the async-only ones called out. `examples/blocking_api/system/`, `examples/async_api/system/` : Cross-cutting device scripts (identification, discovery, analog rails). `examples/legacy/` : The legacy compatibility shims. See the migration guide, not these pages. Each script's module docstring carries its own **Hardware setup** and **Workflow** sections; read that before wiring anything. Several examples select an adapter by model (`Device.open(model=DeviceModel.SUPERNOVA)`) rather than taking whatever enumerates first, which matters as soon as more than one adapter is plugged in. ### The whole suite at once `examples/run_all_examples.py` discovers every example, runs each in its own subprocess so a left-over peripheral state cannot bleed into the next, streams the output live, and prints a PASS / SKIP / FAIL summary: ```bash uv run python examples/run_all_examples.py # standard examples uv run python examples/run_all_examples.py --complete # + the opt-in ones uv run python examples/run_all_examples.py --dry-run # list them, run nothing uv run python examples/run_all_examples.py blocking_api # one API suite uv run python examples/run_all_examples.py uart # UART, both APIs uv run python examples/run_all_examples.py async_api/uart # only the async UART ones ``` The argument matches a contiguous run of folder names anywhere under `examples/`: a full prefix selects one API suite, a bare category name selects that folder across both APIs. Exit code is `0` when nothing FAILED — skips do not fail the run. **SKIP is a real outcome, and it is what makes the report usable on a bare adapter.** An example needing more than an adapter on its own declares it in a module-level `OPTIONAL_EXAMPLE`, which the runner reads out of the source without importing it and prints as a manifest before anything runs. When the peripheral turns out to be absent the example exits with the shared skip code, so the summary separates *verified* from *needs hardware you do not have* from *actually broken* rather than showing a wall of failures. Declarations that also set `opt_in` are left out until `--complete` is passed. The reference bench the suite is written against is an I2C target on bus A at 7-bit address `0x50` with 2-byte sub-addressing, and a UART TX-to-RX loopback jumper so one adapter can demonstrate a round-trip. `examples/criteria.md` has it in full, including what each protocol does when nothing is wired — on a bare bus `i2c.scan()` **raises** `FW_I2C_BUS_WITH_NO_TARGETS_CONNECTED` rather than returning an empty result. ## What every protocol has in common ### Bring-up is `bring_up()` I2C, I3C, UART and SPI each have a `bring_up(...)`, on both the blocking class and its asyncio twin — and so do the I2C and SPI **target** interfaces, which brings the count to six. It is the recommended way to bring any of them up, and the one to reach for first: ```python result = i2c.bring_up(frequency_hz=400_000, pull_up=I2cPullUp.OHM_330) ``` It exists because the obvious primitive is a trap. `initialize()` is **not idempotent** and interface state **survives a reconnect**, so the *second run of any script* — not some edge case — lands on `FW_INTERFACE_ALREADY_INITIALIZED` unless the adapter was reset in between. Recovering from that by hand takes about fifteen lines and two `except` arms, one of which must not be status-filtered; two shipped attempts in this repository got it wrong, invisibly. `bring_up` is that recovery, written once, in library code, with tests. **What it promises**, and the split is the whole point: - **On return the interface is initialised and usable.** Anything that prevents that raises, so there is no half-success to check for. - **`settings_applied` says whether it is running at *your* settings.** It is `False` in the usual case because the interface was already up from an earlier run and the adapter refused to reconfigure it. The interface still works — at settings that are not the ones you asked for, and that are not reported. One case is not a refused reconfigure and belongs to UART alone: a `hw_handshake` this SDK's capability table already knows the unit forces off (see the [UART guide](uart.md)). Either way the field is a *report*, not a promise — `True` means no refusal this SDK can see, not a read-back confirmation. So a caller whose claim depends on specific settings — a rig measuring a timing margin, a script that must run at 100 kHz — has to branch: ```python result = i2c.bring_up(frequency_hz=100_000, pull_up=I2cPullUp.OHM_330) if not result.settings_applied: raise result.refusal ``` {py:obj}`~pycosmicsdk.BringUpResult` carries three fields. `settings_applied` is the one above. `already_initialized` says whether the interface was already up — `True` is the *normal* reading on any second run. `refusal` is the exception the refused reconfigure raised, or `None`; `raise result.refusal` turns the report back into an exception for a caller that wants no leniency at all. There is deliberately no `__bool__`: a result object that is silently falsy invites the exact "proceeded anyway" mistake this exists to prevent. A script that does not care — most scripts, and every example that only needs *a* working bus — ignores the return value entirely and is still correct. The eight helpers in `examples/helpers.py` delegate to these methods and add the examples' console lines; they carry no fallback of their own. ### The primitives underneath `initialize(...)` configures the peripheral and returns the interface, so it chains, which is what you want when you know the interface is down: ```python i2c = dev.i2c(bus=I2cBus.A).initialize(frequency_hz=400_000, pull_up=I2cPullUp.OHM_330) ``` `configure(...)` takes the same arguments (bar UART's `send_only_mode`, which is bring-up only) and returns `None`; it is the re-configure of an interface that is already up. Both remain public and supported — reach for `initialize` directly when you specifically want its faithful `FW_INTERFACE_ALREADY_INITIALIZED`, and for `configure` when the interface is known to be up already. If you do write the fallback by hand, copy the shape in the [I2C guide](i2c.md) rather than inventing it, and keep its second `except` clause: {py:obj}`~pycosmicsdk.I2cController.configure` can be refused *before* the wire by a capability guard, and that refusal is a {py:obj}`~pycosmicsdk.CapabilityError`, not a `FirmwareError`. See [Capability refusals arrive before the wire](../reference/errors.md) for why it needs its own unfiltered clause. ### Who has no `bring_up()`, and why Two interfaces lack one, for two different reasons — neither is an oversight. Note which two: **{py:obj}`~pycosmicsdk.I2cTarget` and {py:obj}`~pycosmicsdk.SpiTarget` both have one**, so "target mode has no bring-up" is no longer true of this SDK, and the reason those two do is the exact inverse of the reason the I3C target does not. [Target mode](target-mode.md) has the comparison. {py:obj}`~pycosmicsdk.Gpio` has **no `initialize`/`configure` pair at all** for a composite to sit on top of. Pins are configured one at a time with {py:obj}`~pycosmicsdk.Gpio.configure_pin`, which is idempotent in the way that matters — re-running a script re-configures a pin without complaint. The CCC sub-interface has none either; it inherits the controller's. {py:obj}`~pycosmicsdk.I3cTarget` has none either, for a **different reason**, and the two are worth telling apart. It *does* carry an `initialize`/`configure` pair — but its `initialize` is already idempotent, because upstream's `i3cTargetInit` re-initialises the peripheral unconditionally and never reports `FW_INTERFACE_ALREADY_INITIALIZED`. There is nothing for a composite to absorb, so target mode's whole re-runnability story is: call {py:obj}`~pycosmicsdk.I3cTarget.initialize` on every run. Do **not** wrap it in the try-`initialize`-then-`configure` fallback described above — that arm can never fire, so it is dead code around a call that already does the right thing. The [I3C guide](i3c.md) has the full reasoning, including why a `BringUpResult` could not be honest about a call that changes the adapter's bus role rather than bringing an interface up. ### Everything else every protocol has in common **Accessors are cached and cheap.** `dev.i2c(bus=...)`, `dev.uart()`, `dev.spi()` and friends do no I/O; they build the interface object on first use and return the same object afterwards. On an {py:obj}`~pycosmicsdk.AsyncDevice` they are not coroutines. **Capability checks happen before the wire.** Asking a Pulsar for {py:obj}`~pycosmicsdk.Device.i3c` raises {py:obj}`~pycosmicsdk.CapabilityError` immediately — see `examples/blocking_api/i3c/capability_rejection.py`. It is not only whole protocols: {py:obj}`~pycosmicsdk.I2cController.configure`, {py:obj}`~pycosmicsdk.I2cController.set_pull_up`, {py:obj}`~pycosmicsdk.I2cController.initialize` and {py:obj}`~pycosmicsdk.Gpio.configure_interrupt` each sit behind a guard for a model- or revision-specific limit; the bulk transport is negotiated rather than assumed, for all three protocols that can use it; a SPI target bring-up is refused outright on a hardware revision that cannot receive a chip select; and both target-mode arenas refuse to arm when the host cannot reach them. {py:obj}`~pycosmicsdk.DeviceCapabilities` answers all of them in advance with no USB traffic — `capabilities.i2c_runtime_reconfigurable`, `capabilities.supports_i2c_pull_up(pull_up)`, `capabilities.supports_gpio_interrupt(pin)`, `capabilities.spi_target_capable`, `capabilities.supports_spi_bulk()` and `capabilities.supports_i3c_bulk()`. Asking is cheaper than catching, and reads better — see [Capabilities you can ask about in advance](../reference/types.md). Each of those guards refuses only what it has positive grounds to refuse: a model the limit does not apply to, or a hardware revision string this SDK cannot read, is attempted and left to firmware to answer. So a {py:obj}`~pycosmicsdk.CapabilityError` is always a specific, sourced claim about the connected unit rather than a default, and its absence is never a promise that the operation will succeed. [Refusals fail open](../reference/errors.md) has the reasoning and the one place it does not apply. **Payloads are `bytes`.** In and out, everywhere. **Voltage is shared.** I2C, SPI, UART and GPIO sit on one multifunction VTARG rail, so `set_voltage` on any of them moves all four. I3C has its own high- and low-voltage rails. ## Testing your own code [Testing without hardware](testing.md) covers `pycosmicsdk.testing`, the package that lets you write tests for code driving an adapter you do not have on the desk: a fake adapter hands back a real {py:obj}`~pycosmicsdk.Device` or {py:obj}`~pycosmicsdk.AsyncDevice` with only the compiled facade underneath substituted. ```{toctree} :hidden: :maxdepth: 1 lifecycle i2c i3c uart spi gpio debugprobe bulk target-mode testing ```