SPI
SpiController drives the SPI bus of an open adapter,
from Device.spi(). Both the Supernova and
the Pulsar have it. The async twin is
AsyncSpiController.
SPI is full duplex, so one operation — transfer — covers everything. Two
half-duplex convenience operations sit beside it, write and read, and they
are the only route to the larger bulk-transport payload ceiling.
Bring-up
from pycosmicsdk import (
Device, SpiBitOrder, SpiChipSelect, SpiChipSelectPolarity, SpiDataWidth, SpiMode,
)
with Device.open() as dev:
spi = dev.spi()
spi.set_voltage(3300)
spi.bring_up(
bit_order=SpiBitOrder.MSB,
mode=SpiMode.MODE_0,
data_width=SpiDataWidth.BITS_8,
chip_select=SpiChipSelect.CS_0,
chip_select_polarity=SpiChipSelectPolarity.ACTIVE_LOW,
frequency_hz=1_000_000,
)
bring_up is the re-runnable bring-up and
the one to reach for: it tries
initialize, falls back to
configure when the controller is already
up, and returns a BringUpResult. On return the
controller is up and usable. The contract is shared with I2C, UART and I3C and
is explained once under Bring-up is bring_up().
All six bring-up arguments are keyword-only and all six are required — there
are no defaults to get wrong silently. mode is the usual CPOL/CPHA pair
(MODE_0 … MODE_3), chip_select selects one of four lines (CS_0 …
CS_3), data_width is 8 or 16 bits.
Two SPI-specific things are worth knowing about the result.
configure reaches every parameter
initialize takes, so the fallback requests exactly what the fresh path would
have — there is no SPI setting that survives only the initialize route. And
no capability guard gates SPI reconfiguration today, so in practice
settings_applied is False only if firmware rejects a value it accepted at
first bring-up. Check it anyway when the clock rate matters:
result = spi.bring_up(
bit_order=SpiBitOrder.MSB,
mode=SpiMode.MODE_0,
data_width=SpiDataWidth.BITS_8,
chip_select=SpiChipSelect.CS_0,
chip_select_polarity=SpiChipSelectPolarity.ACTIVE_LOW,
frequency_hz=10_000_000,
)
if not result.settings_applied:
raise result.refusal
Underneath, initialize is not
idempotent: a re-run on an already-initialised interface reports
FW_INTERFACE_ALREADY_INITIALIZED, and the interface stays initialised across
reconnects. examples/helpers.py’s spi_bring_up() delegates to
bring_up and adds the examples’ console
lines — it previously let a capability refusal escape entirely and now reports
it. examples/blocking_api/spi/mode_frequency_sweep.py sweeps modes,
frequencies and chip selects to show the difference between initialize and
configure.
The rail is the shared multifunction VTARG, so
set_voltage moves I2C, UART and GPIO with
it. get_bus_voltage reads it back, and
AsyncSpiController mirrors it.
Transfer
response = spi.transfer(b"\x9f\x00\x00\x00")
transfer clocks data out on MOSI and
returns what arrived on MISO in the same frames — so the response is the
same length as the request, and a request whose reply you care about needs
padding bytes to clock it back in. The JEDEC “Read ID” pattern the examples use
is exactly that: one command byte plus dummy bytes.
An unconnected MISO line reads back a constant (all 0x00 or all 0xFF). That
is an expected, informative result, not a failure.
The payload cap on transfer is 1024 bytes, and an over-long payload raises
ValidationError before any USB traffic. The I2C, I3C and
UART caps are enforced the same way — this page used to call those three
advisory and this one the exception, which was wrong in the direction that
matters, since it invited a caller to send a payload the SDK refuses. What is
different about SPI’s is only its source: it is a constant in this SDK rather
than a field on DeviceCapabilities.
Half-duplex write and read
Most SPI targets are not really full duplex. A flash page program does not care
what came back on MISO; a “read register” command is a command phase out then a
data phase in. write and
read are those two shapes, with chip
select asserted across the whole exchange exactly once:
spi.write(b"\x02\x00\x00\x00" + b"\xa5" * 256) # page program, MISO discarded
device_id = spi.read(b"\x9f", 3) # command out, 3 bytes back
read returns only the data phase — the
command bytes are not echoed back, so there is no slicing at the call site.
That is the ergonomic difference from transfer, where a reply you care about
needs padding bytes and a slice.
transfer is unchanged and still
supported, and it is the backwards-compatible path: every existing caller
keeps working byte for byte. Prefer write / read for new code when the
reply frames are not wanted, because only they can reach the bulk transport’s
larger ceiling — transfer has to hand back MISO, and the bulk write command
does not carry it.
Which transport a transfer takes
Large payloads can travel over the host’s bulk USB plane instead of HID.
SpiTransport selects: AUTO (the default — bulk where
available, HID otherwise, and the portable choice), BULK (require it, raising
CapabilityError before any request when it is not
available) and HID (never negotiate, which is how a test forces the
fallback).
SpiController.transport answers
which one a transfer will take, before the transfer rather than as
after-the-fact state — it is a stable property of the connection, because the
negotiation is latched per connection:
print(f"large SPI payloads travel over {spi.transport.value}")
DeviceCapabilities.bulk is the
full BulkTransportCapability record, and
dev.capabilities.supports_spi_bulk() is the one-line question. It is the one
capability field that is not a property of the device alone: the bulk plane
needs a vendor USB interface claimed on this machine, so the same adapter can
answer differently on two PCs. That is why host_transport_open and
device_supported are reported separately — “bind a driver here” and “update
the adapter’s firmware” are different fixes — and why queried distinguishes
“not asked yet” from a real no.
Two things about that record are worth knowing here, and are explained in full
under The bulk transfer plane. It is the latch for all three
protocol groups, not just SPI — which is why the field is named bulk, the
old spi_bulk spelling surviving only as a deprecated alias property. And
device_supported reading
True is not evidence that the endpoints exist: firmware effectively
hard-wires that field, and goes on advertising bulk endpoints that are not on
the wire when WebUSB is disabled. host_transport_open is the field that
actually discriminates.
The ceiling on the bulk plane is what the device advertised for its SPI command
group — 32768 bytes on a Supernova, 16384 on a Pulsar. Read it rather than
assuming a model:
capabilities.bulk.max_spi_payload_bytes.
Nothing is ever split across two calls. A request over the active transport’s
ceiling raises ValidationError naming that transport and
its ceiling, because chip select de-asserts between calls and a split write
would cut a flash page program in half. One INFO line per connection on the
pycosmicsdk.spi logger records the selection, so a throughput number is never
reported without the transport that produced it.
One asymmetry between write and read is worth having in mind before you
size a chunk. On the bulk plane the two phases of a read are bounded
separately — the command phase rides the HID request, the data phase the
bulk endpoint — while on the HID route they are bounded jointly, so
len(command) + length must fit one 1024-byte frame. A 4-byte command
therefore leaves room for 1020 bytes of data on HID and a full ceiling’s worth
on bulk.
Note
write, read and transport are blocking-only, deliberately.
AsyncSpiController does not mirror them, and should not
be “finished”. Two independent reasons. Upstream’s async bulk submit pushes the
payload from inside the submit against a two-second budget, so a submit this
package guarantees to be non-blocking could stall the event loop and every
coroutine on it. And a bulk read cannot be correlated on an unframed byte
stream, only isolated, so a coroutine would have to hold a
connection-exclusive lock across its own await — a blocking operation with
async def in front of it.
A HID-only mirror of the pair was considered and not taken: write and read
are transport-negotiating methods, and an async twin pinned to the 1024-byte
route would mean something narrower than its blocking namesake. I2C’s and I3C’s
async transfer methods did ship in that narrowed shape — with transport
dropped from the signature to make the narrowing visible — so this is a
judgement about the SPI pair, not a rule the package applies everywhere; see
Blocking and asyncio, which reconciles the two. The honest
asyncio shape is loop.run_in_executor around the blocking call. See also
The plane is blocking-only.
transfer is unaffected and remains a
full mirror.
Target mode
The adapter can also be the target on a SPI bus, answering an external
controller out of a memory arena instead of driving one.
Device.spi_target() is the accessor
and SpiTarget the interface; there is an
AsyncSpiTarget twin with configuration and bus events but
no arena access.
Three things to know before calling it, all covered in Target mode:
It takes the SPI peripheral until you hand it back — which is one call. The role is exclusive, so a controller bring-up while the target holds it is refused, and the arena is the shared staging pool, so a program holding a SPI target cannot also arm an I2C one. Both are constraints on what can be up at once:
SpiTarget.releasesends the device’sSPI_DEINITand returns the role and the arena together, after which the other role — or the other protocol’s target — comes up normally.SpiController.releasesends the same parameterless command, because firmware dispatches on the role the peripheral actually holds; it exists so a rev-B Supernova, whichDevice.spi_target()refuses outright, still has a way to hand a role back. Mind that a release frees the arena with the role —arena_sizegoes back toNoneand staged data is gone — that an idle peripheral answersFW_INTERFACE_NOT_INITIALIZEDrather than succeeding quietly, and that SPI is released before I2C when a program holds both. This page said until 2026-09-04 that the role cost you the peripheral for the power cycle and thatDevice.rebootwas the only release from Python: that was true of an older CosmicSDK pin, and is now the fallback only on firmware predating the deinit dispatch, which answersFW_UNSUPPORTED_COMMANDand cannot be told apart by version string.A rev-B Supernova cannot do it, and firmware does not say so: it answers
SUCCESSand then never sees a chip-select edge, because that revision’s level shifter has its chip-select direction pin fixed as an output. This SDK refuses it instead — the gate is ours, not the device’s. Branch oncapabilities.spi_target_capable.The arena is bulk-only. There is no HID route to
SpiTarget.write_memoryorSpiTarget.read_memory, so bring-up refuses up front on a host that cannot claim the vendor interface, rather than arming a target nobody can seed.
transfer is unaffected by any of this and
needs no bulk plane at all.
Errors: SpiError fires only in target mode
SpiError has exactly one source: SPI target mode.
Both status translators map the 0x03xx firmware band to it, and all seven codes
in that band are target-mode ones — FW_SPI_ROLE_CONFLICT,
FW_SPI_TARGET_STAGING_UNAVAILABLE, FW_SPI_TARGET_SURPASSED_MEMORY_SIZE,
FW_SPI_TARGET_PAYLOAD_TRANSFER_FAILED, FW_SPI_TARGET_WRONG_MODEL,
FW_SPI_TARGET_INVALID_CONFIG and FW_SPI_TARGET_REGION_TABLE_FULL. So a
program that never calls Device.spi_target() will never see one, and this page’s earlier
advice — that the class was dead code — is now true of controller code only.
Controller-side SPI failures still arrive as something else:
Failure |
Code |
Exception |
|---|---|---|
Rejected configuration |
|
|
Interface not initialised |
|
|
Rejected voltage |
0x01xx system band |
|
Over-long payload, bad field width |
(no wire traffic) |
|
|
(no wire traffic) |
|
SPI target bring-up on a rev-B Supernova |
(no wire traffic) |
|
Target arena unreachable from this host |
(no wire traffic) |
|
Target bring-up while the controller holds the role |
|
|
Second target protocol while the first still holds the arena |
|
So SpiError is not sufficient on its own for
controller code. Catch its base FirmwareError to cover
every firmware-reported SPI failure. It is the right class to catch around a
SPI target bring-up.
Voltage is enforced by the firmware, and a rejected one comes back as
SystemFirmwareError. Field widths and the payload size
are checked Python-side.
Warning
The clock frequency is enforced by nobody. Firmware checks only that
frequency_hz is non-zero, hands the value to the MCU’s SPI init, and
discards that call’s return value — so an unachievable rate is not refused. You
get SUCCESS and whatever clock the hardware divider actually produced, which
may be some way from what you asked for and is never reported back. There is no
call that reads the configured rate out of the adapter, so this SDK cannot
check it either, and BringUpResult settings_applied
being True means “the command was accepted”, not “the bus is running at that
rate”.
Upstream documents the usable range as 10 kHz–50 MHz and adds that the maximum varies with the connection; the Supernova datasheet says 37.5 MHz. Nobody has measured where the real ceiling sits on either adapter. If the SPI clock rate matters to your result, put a scope on SCK — that is the only way to know.
Examples
examples/blocking_api/spi/transfer.py— bring-up and one full-duplex transfer. Requires no SPI peripheral: it is safe to run with nothing wired, and the docstring explains how to read the result.mode_frequency_sweep.py— several distinct configurations in one run, exercisinginitializeversusconfigure.transport_selection.py— which host transport the negotiation chose on this machine, and what made it choose so. Needs no peripheral and no bulk driver; the content of the report is the result. The one example that configures logging.bulk_flash_read.py— 16 KB out of a real SPI NOR flash in one call, proved against sixteen chunked reads byte for byte. Needs a flash part and a host holding the vendor interface.target_flash_driven.py— two adapters, one emulating a flash part for the other to read. Mind that a rev-B Supernova cannot be the target.examples/async_api/spi/transfer.py— the async twin, which selects the Pulsar by model to show the same flow runs unchanged on either adapter.
Reference
pycosmicsdk.SpiMode,pycosmicsdk.SpiBitOrder,pycosmicsdk.SpiDataWidth,pycosmicsdk.SpiChipSelect,pycosmicsdk.SpiChipSelectPolarity,pycosmicsdk.SpiTransportpycosmicsdk.SpiTarget/pycosmicsdk.AsyncSpiTarget,pycosmicsdk.SpiTargetModel,pycosmicsdk.SpiTargetEngine,pycosmicsdk.SpiTargetBusEvent,pycosmicsdk.SpiTargetBusEventKindpycosmicsdk.BulkTransportCapability,pycosmicsdk.BringUpResult