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.release sends the device’s SPI_DEINIT and returns the role and the arena together, after which the other role — or the other protocol’s target — comes up normally. SpiController.release sends the same parameterless command, because firmware dispatches on the role the peripheral actually holds; it exists so a rev-B Supernova, which Device.spi_target() refuses outright, still has a way to hand a role back. Mind that a release frees the arena with the role — arena_size goes back to None and staged data is gone — that an idle peripheral answers FW_INTERFACE_NOT_INITIALIZED rather 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 that Device.reboot was 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 answers FW_UNSUPPORTED_COMMAND and cannot be told apart by version string.

  • A rev-B Supernova cannot do it, and firmware does not say so: it answers SUCCESS and 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 on capabilities.spi_target_capable.

  • The arena is bulk-only. There is no HID route to SpiTarget.write_memory or SpiTarget.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

FW_INVALID_PARAMETER

FirmwareError

Interface not initialised

FW_INTERFACE_NOT_INITIALIZED

FirmwareError

Rejected voltage

0x01xx system band

SystemFirmwareError

Over-long payload, bad field width

(no wire traffic)

ValidationError

transport=SpiTransport.BULK with no bulk plane

(no wire traffic)

CapabilityError

SPI target bring-up on a rev-B Supernova

(no wire traffic)

CapabilityError

Target arena unreachable from this host

(no wire traffic)

CapabilityError

Target bring-up while the controller holds the role

FW_SPI_ROLE_CONFLICT

SpiError

Second target protocol while the first still holds the arena

FW_SPI_TARGET_STAGING_UNAVAILABLE

SpiError

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, exercising initialize versus configure.

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