# SPI {py:obj}`~pycosmicsdk.SpiController` drives the SPI bus of an open adapter, from {py:obj}`Device.spi() `. Both the Supernova and the Pulsar have it. The async twin is {py:obj}`~pycosmicsdk.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 ```python 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, ) ``` {py:obj}`~pycosmicsdk.SpiController.bring_up` is the re-runnable bring-up and the one to reach for: it tries {py:obj}`~pycosmicsdk.SpiController.initialize`, falls back to {py:obj}`~pycosmicsdk.SpiController.configure` when the controller is already up, and returns a {py:obj}`~pycosmicsdk.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()`](index.md). 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. {py:obj}`~pycosmicsdk.SpiController.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: ```python 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, {py:obj}`~pycosmicsdk.SpiController.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 {py:obj}`~pycosmicsdk.SpiController.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 {py:obj}`~pycosmicsdk.SpiController.set_voltage` moves I2C, UART and GPIO with it. {py:obj}`~pycosmicsdk.SpiController.get_bus_voltage` reads it back, and {py:obj}`~pycosmicsdk.AsyncSpiController` mirrors it. ## Transfer ```python response = spi.transfer(b"\x9f\x00\x00\x00") ``` {py:obj}`~pycosmicsdk.SpiController.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 {py:obj}`~pycosmicsdk.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 {py:obj}`~pycosmicsdk.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. {py:obj}`~pycosmicsdk.SpiController.write` and {py:obj}`~pycosmicsdk.SpiController.read` are those two shapes, with chip select asserted across the whole exchange exactly once: ```python 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 ``` {py:obj}`~pycosmicsdk.SpiController.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. {py:obj}`~pycosmicsdk.SpiController.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. {py:obj}`~pycosmicsdk.SpiTransport` selects: `AUTO` (the default — bulk where available, HID otherwise, and the portable choice), `BULK` (require it, raising {py:obj}`~pycosmicsdk.CapabilityError` before any request when it is not available) and `HID` (never negotiate, which is how a test forces the fallback). {py:obj}`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: ```python print(f"large SPI payloads travel over {spi.transport.value}") ``` {py:obj}`DeviceCapabilities.bulk ` is the full {py:obj}`~pycosmicsdk.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](bulk.md). 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: {py:obj}`capabilities.bulk.max_spi_payload_bytes `. Nothing is ever split across two calls. A request over the active transport's ceiling raises {py:obj}`~pycosmicsdk.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.** {py:obj}`~pycosmicsdk.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](../asyncio.md), which reconciles the two. The honest asyncio shape is `loop.run_in_executor` around the blocking call. See also [The plane is blocking-only](bulk.md). {py:obj}`~pycosmicsdk.AsyncSpiController.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. {py:obj}`Device.spi_target() ` is the accessor and {py:obj}`~pycosmicsdk.SpiTarget` the interface; there is an {py:obj}`~pycosmicsdk.AsyncSpiTarget` twin with configuration and bus events but no arena access. Three things to know before calling it, all covered in [Target mode](target-mode.md): - **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*: {py:obj}`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. {py:obj}`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 {py:obj}`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 {py:obj}`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 {py:obj}`capabilities.spi_target_capable `. - **The arena is bulk-only.** There is no HID route to {py:obj}`SpiTarget.write_memory ` or {py:obj}`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. {py:obj}`~pycosmicsdk.SpiController.transfer` is unaffected by any of this and needs no bulk plane at all. ## Errors: `SpiError` fires only in target mode {py:obj}`~pycosmicsdk.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 {py:obj}`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` | {py:obj}`~pycosmicsdk.FirmwareError` | | Interface not initialised | `FW_INTERFACE_NOT_INITIALIZED` | {py:obj}`~pycosmicsdk.FirmwareError` | | Rejected voltage | 0x01xx system band | {py:obj}`~pycosmicsdk.SystemFirmwareError` | | Over-long payload, bad field width | *(no wire traffic)* | {py:obj}`~pycosmicsdk.ValidationError` | | `transport=SpiTransport.BULK` with no bulk plane | *(no wire traffic)* | {py:obj}`~pycosmicsdk.CapabilityError` | | SPI target bring-up on a rev-B Supernova | *(no wire traffic)* | {py:obj}`~pycosmicsdk.CapabilityError` | | Target arena unreachable from this host | *(no wire traffic)* | {py:obj}`~pycosmicsdk.CapabilityError` | | Target bring-up while the controller holds the role | `FW_SPI_ROLE_CONFLICT` | {py:obj}`~pycosmicsdk.SpiError` | | Second target protocol while the first still holds the arena | `FW_SPI_TARGET_STAGING_UNAVAILABLE` | {py:obj}`~pycosmicsdk.SpiError` | So {py:obj}`~pycosmicsdk.SpiError` is **not sufficient on its own** for controller code. Catch its base {py:obj}`~pycosmicsdk.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 {py:obj}`~pycosmicsdk.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 {py:obj}`~pycosmicsdk.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 - {py:obj}`pycosmicsdk.SpiController` / {py:obj}`pycosmicsdk.AsyncSpiController` - {py:obj}`pycosmicsdk.SpiMode`, {py:obj}`pycosmicsdk.SpiBitOrder`, {py:obj}`pycosmicsdk.SpiDataWidth`, {py:obj}`pycosmicsdk.SpiChipSelect`, {py:obj}`pycosmicsdk.SpiChipSelectPolarity`, {py:obj}`pycosmicsdk.SpiTransport` - {py:obj}`pycosmicsdk.SpiTarget` / {py:obj}`pycosmicsdk.AsyncSpiTarget`, {py:obj}`pycosmicsdk.SpiTargetModel`, {py:obj}`pycosmicsdk.SpiTargetEngine`, {py:obj}`pycosmicsdk.SpiTargetBusEvent`, {py:obj}`pycosmicsdk.SpiTargetBusEventKind` - {py:obj}`pycosmicsdk.BulkTransportCapability`, {py:obj}`pycosmicsdk.BringUpResult` - [The bulk transfer plane](bulk.md), [Target mode](target-mode.md)