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 |
|---|---|---|
Supernova, Pulsar |
||
|
Supernova only |
|
Supernova, Pulsar |
||
Supernova, Pulsar |
||
Supernova, Pulsar |
||
|
Atira |
Two pages cut across the protocols rather than describing one, and both are new:
Guide |
What it covers |
Adapters |
|---|---|---|
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 |
Supernova, Pulsar (I3C bulk: Supernova only) |
|
I2C and SPI acting as a target rather than a controller: taking the bus role and handing it back with |
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
DeviceAPI.examples/async_api/<protocol>/asyncioAsyncDeviceAPI. 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_appliedsays whether it is running at your settings. It isFalsein 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: ahw_handshakethis SDK’s capability table already knows the unit forces off (see the UART guide). Either way the field is a report, not a promise —Truemeans 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.