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 covers discovery, the three open() selectors, the states a 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

I2cController, AsyncI2cController

Supernova, Pulsar

I3C

I3cController, I3cCcc, I3cTarget + async twins

Supernova only

UART

Uart, AsyncUart

Supernova, Pulsar

SPI

SpiController, AsyncSpiController

Supernova, Pulsar

GPIO

Gpio, AsyncGpio

Supernova, Pulsar

Atira debug probe

DebugProbe, 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

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

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 pycosmicsdk-examples.zip, unzip, pip install pycosmicsdk, and run them with python from inside the unzipped folder. From a source checkout:

uv run python examples/blocking_api/i2c/blocking.py

Layout, per examples/criteria.md:

examples/blocking_api/<protocol>/

Blocking Device API.

examples/async_api/<protocol>/

asyncio 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:

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:

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). 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:

result = i2c.bring_up(frequency_hz=100_000, pull_up=I2cPullUp.OHM_330)
if not result.settings_applied:
    raise result.refusal

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:

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 rather than inventing it, and keep its second except clause: configure can be refused before the wire by a capability guard, and that refusal is a CapabilityError, not a FirmwareError. See Capability refusals arrive before the wire 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: I2cTarget and 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 has the comparison.

Gpio has no initialize/configure pair at all for a composite to sit on top of. Pins are configured one at a time with 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.

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 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 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 AsyncDevice they are not coroutines.

Capability checks happen before the wire. Asking a Pulsar for i3c raises CapabilityError immediately — see examples/blocking_api/i3c/capability_rejection.py. It is not only whole protocols: configure, set_pull_up, initialize and 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. 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.

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 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 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 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 Device or AsyncDevice with only the compiled facade underneath substituted.