The bulk transfer plane

Every ordinary call in this SDK carries its payload inside a HID request, and a HID request holds 1024 bytes. The adapters also expose a vendor USB interface with a bulk endpoint pair, and a handful of commands can move their payload over that instead — the same frames, on the same pins, at the same rate, with a larger per-call ceiling. Nothing else about the transfer changes.

That is the whole of the feature, and it is worth being blunt about what it is not: it is not a bus mode, not a speed setting, and not something you turn on. Which route a call takes is negotiated once per connection and selected per call, and the only thing you can observe from Python is how many bytes one call accepts.

This page is the cross-protocol account: which operations can use the plane, what the ceilings are, how each protocol’s AUTO behaves, and how to find out why the plane is unavailable when it is. The protocol pages (I2C, SPI, I3C) cover the per-call detail.

Which operations can use it

Six cells in the grid of {I2C, SPI, I3C} × {controller, target memory}. Five of them are on the plane.

Controller transfers

Target memory

I2C

yes

yes — bulk-only, no HID route

SPI

yes

yes — bulk-only, no HID route

I3C

yes, Supernova only, all three transfer modes

none, by design

Two rows of that table need reading carefully, because both of them retract something this SDK’s own design notes used to say.

I3C is not absent from the plane. Earlier documents in this repository state that I3C has no bulk path at all, in either role. That is half wrong and half right, and the halves have to be stated separately. For the controller role it is wrong in all three of I3C’s transfer modes: the device implements one bulk command, I3C_CONTROLLER_PRIVATE_TRANSFER_BULK, and CosmicSDK wraps it six times — SDR, HDR-DDR and legacy-I2C, write and read. One firmware command, one capability bit, six methods. It is compiled only into the part that has an I3C peripheral, so a Pulsar advertises no I3C command group at all.

I3C target memory has none, and that is a design choice rather than an omission. I3cTarget serves its bus out of a private 1024-byte buffer, not out of the shared staging pool the I2C and SPI target arenas come from, and its memory commands are HID-carried and correspondingly absent from the I3C command mask — while the I2C and SPI target-memory commands were added to theirs. So write_memory and read_memory take no transport argument and never will, and there is deliberately no I3C target member of BulkOperation — a member for it would be a value that can never be True.

The five reachable cells are the nine members of BulkOperation, which is the vocabulary BulkTransportCapability.supports takes:

BulkOperation member

What it carries

SPI_CONTROLLER_WRITE, SPI_CONTROLLER_READ

write / read

I2C_CONTROLLER_WRITE, I2C_CONTROLLER_READ

write / read

SPI_TARGET_WRITE_MEMORY, SPI_TARGET_READ_MEMORY

SpiTarget.write_memory / SpiTarget.read_memory

I2C_TARGET_WRITE_MEMORY, I2C_TARGET_READ_MEMORY

I2cTarget.write_memory / I2cTarget.read_memory

I3C_CONTROLLER_TRANSFER

all six I3cController private-transfer methods

Support is advertised per command, ceilings per command group, and the two are different questions. A device can advertise a usable ceiling for a group and not advertise one of the commands in it — the SPI group alone holds four bulk commands across two bus roles — so BulkTransportCapability answers them separately rather than deriving one from the other. Collapsing them was a real defect: a device offering the SPI target-memory pair but no SPI controller read used to read as a device with no bulk plane at all.

The ceilings

Per model and per protocol, and one of them is not the number you would guess.

Supernova

Pulsar

Ordinary HID route, every protocol

1024

1024

I2C command group

32768

16384

SPI command group

32768

16384

I3C command group

32767

no I3C hardware, no group

The I2C and SPI figures are each that model’s whole bulk staging arena, and both are reached in a single call on hardware rather than inferred. A Pulsar’s plane is not new or conditional either — it is present on the shipped image, at 16384 for both protocols and both bus roles. Do not carry an older number: the Pulsar figure moved 2048 → 8192 → 16384 → 11264 → 16384 over the course of the firmware work, and 16384 is current. Any prose saying “Pulsar 2048” is four ceiling changes stale.

Important

For I3C the maximum is 32767, not the 32768-byte arena, and the one-byte gap is real rather than a rounding slip. Every other ceiling on this page is a buffer size. This one is a hardware frame limit: the I3C peripheral’s transaction-length field is 15 bits, so 0x7FFF is the longest frame it can describe. The ceiling therefore does not track the staging arena and will not move if the arena does.

The practical consequence, stated plainly because this is the one place where the obvious expectation is wrong: I2C and SPI reach their model’s whole arena; I3C stops one byte short of it. A caller who reads “32 KB on a Supernova”, asks an I3C transfer for 32768 bytes and gets a ValidationError has not hit a bug — the SDK is refusing a frame the peripheral cannot describe, and the message says which number it used and why. Ask for 32767; better, ask max_payload_for and use what it returns, which is the habit that survives a firmware change.

Firmware agrees with that number and enforces it too, so the host-side clamp is a belt-and-braces agreement rather than the only line of defence: current firmware advertises 32767 for the I3C group and refuses an over-size frame itself. What the local clamp buys is a refusal with no round trip and a message naming the ceiling.

Why the limit is worth refusing rather than attempting is a bench result rather than a datasheet reading, and it explains the belt and braces: a 32768-byte frame does not error. It reports success with the last byte never put on the bus, and leaves the I3C engine non-idle so the next transfer fails too — measured as a silently dropped write followed by a good, smaller write that also failed until the controller was re-initialised. That is the failure mode both ends are avoiding.

Two further deductions that no advertised ceiling can express, both of which will otherwise surprise you:

  • An I3C SDR write shares its frame with the register sub-address, so the usable payload is the ceiling minus len(subaddress). A read has no such deduction.

  • HDR-DDR is capped at 1024 bytes on both routes, and the advertised I3C group ceiling does not apply to it. See below.

Read the number that applies to your connection rather than assuming a model: I2cController.max_transfer_bytes and SpiTarget.max_transfer_bytes and their siblings answer it in one call, and BulkTransportCapability.max_payload_for answers it per protocol.

from pycosmicsdk import BulkOperation, Device, ProtocolInterface

with Device.open() as dev:
    bulk = dev.capabilities.bulk
    print(f"host holds the vendor interface: {bulk.host_transport_open}")
    print(f"device advertises a bulk command set: {bulk.device_supported}")
    for protocol in (ProtocolInterface.I2C, ProtocolInterface.SPI, ProtocolInterface.I3C):
        print(f"{protocol.value}: {bulk.max_payload_for(protocol)} bytes")
    print(f"large SPI writes: {bulk.supports(BulkOperation.SPI_CONTROLLER_WRITE)}")

Nothing is ever split

A payload above the active transport’s ceiling raises ValidationError naming that transport and that ceiling. The SDK never silently chunks a request across two calls, and neither does firmware.

That is not conservatism for its own sake. Chip select de-asserts between two SPI calls, so a split write would cut a flash page program in half; a split I2C read would insert a STOP where the caller asked for none. A refusal that names both numbers is the only honest answer.

Which route a call takes

Three enums, one per protocol, each passed as transport=: SpiTransport, I3cTransport and I2cTransport. All three have the same three members and the same meanings for BULK and HID:

BULK

Require the plane. Raises CapabilityError before any request is sent when it is unavailable, and the message names which of the four independent gaps applies — this build’s bindings, this host’s driver, this device’s firmware, or a model with no such hardware. For benchmarks that must not silently measure the wrong transport.

HID

Never use the plane. Short-circuits the negotiation entirely, so it sends nothing extra, and keeps the 1024-byte ceiling whatever the device offers. The way a test forces the fallback.

AUTO is where they differ, and the difference between I2C and SPI is deliberate rather than an inconsistency to be tidied up.

SpiTransport.AUTO and I3cTransport.AUTO : Prefer the plane wherever this host and device can reach it, at any size.

I2cTransport.AUTO

Takes the ordinary HID route for every request that fits its 1024-byte ceiling and reaches for the plane only above it.

Two reasons for that, and both are specific to I2C. There is nothing to win below the ceiling — upstream says so itself: I2C is bus-limited long before the transport is, so what bulk removes is the per-1024-byte command round trip, not the bus time, and a 4-byte register write is bus-bound either way. And there is something to lose: write and read are shipped methods with existing callers, and the bulk route acquires the device’s shared staging pool — the same pool an armed I2C or SPI target draws its arena from — so routing every small write through it would expose those callers to a contention failure they cannot have today, for no throughput gain.

So I2cTransport.AUTO is the transport that never changes the wire for a call that already worked, and lifts the ceiling for one that could not.

Note

There is consequently no transport property on I2cController, where SPI and I3C both have one. SpiController.transport and I3cController.transport can answer “which route will my next transfer take” as a pure function of the connection, because their AUTO does not consult the payload length. I2C’s does, so the same property on I2cController would have to be a function of a request that has not been made yet. The useful question is answered instead by I2cController.max_transfer_bytes, which reports how far AUTO reaches on this connection: 1024 unless both I2C controller bulk commands are reachable, and the advertised group ceiling when they are.

Each interface logs its selection at INFO exactly once per connection, on that protocol’s logger (pycosmicsdk.i2c, pycosmicsdk.spi, pycosmicsdk.i3c), either way round. That is deliberately symmetric and deliberately not a warning: the failure it exists to prevent is somebody publishing a throughput number without recording which transport produced it, and a warning that fires for the working majority teaches people to filter the logger. examples/blocking_api/spi/transport_selection.py is the one example that configures logging, for exactly this reason.

Support is uniform; benefit is not

The capability bit says the route exists. It says nothing about how much any particular operation gains, and the spread is large enough that assuming a uniform speedup will mislead you:

Operation

Payload measured

Measured speed-up

What the plane buys

SPI controller write

32768 B

11.9x

The largest gain on the bench. 32768 bytes in 94.9 ms, 337 KiB/s, against 28.4 KiB/s over HID — where HID needs 32 calls and bulk needs one.

SPI controller write / read

1024 B

7.3x

The last payload HID can still do in one call, so this is the transport cost alone with no chunking in it. Payloads above 1024 bytes become possible at all, and the transport is the binding constraint, so removing it wins most.

I3C SDR read

1024 B

3.45x

The ordinary route carries its bytes inside the request and cannot exceed 1024; the bulk route reaches the advertised group ceiling.

I2C controller read

32768 B

1.62x

Removes the per-1024-byte command round trip, not the bus time.

I3C legacy-I2C

—

not measured

Less again — an I2C target on an I3C bus is bounded by its own open-drain bus rate long before the transport matters.

I3C HDR-DDR

—

none possible

Nothing today. See below.

I2C / SPI target memory

—

not a ratio

There is no HID route to the arena at all, so there is nothing to compare against.

Those figures are one bench, one host, on a Supernova, and the payload column is load-bearing: two of them are 1024-byte cells and two are full-arena cells, and the gain grows with payload size because HID’s rate does not improve with it — above 1024 bytes HID simply adds calls at the same cost each. Treat the table as the shape of the spread rather than as a specification: your USB stack, your payload size and your bus rate all move every row.

Note

Only the I2C row is re-measured on every hardware run. test_h9_bulk_is_not_slower_than_hid_at_the_hid_ceiling in tests/hardware/test_tier_h_bulk_plane.py times both routes and prints both figures, but asserts only the direction (bulk_seconds < hid_seconds) — a pinned throughput number on this transport would flake without catching anything a correctness test misses. The 11.9x, 7.3x and 3.45x figures were recorded once, in a committed docstring, from the 2026-09-04 bench session; nothing measures them again, so a regression in any of the three would be silent. They are measurements, not specifications, and not continuously verified ones.

The I2C figure is not a disappointment, and it is worth knowing why before you go looking for the missing performance. At 400 kHz each byte on the wire costs 9 bit times — eight data bits plus the acknowledge — so the bus’s own ceiling is 400000 / 9 = 43.4 KiB/s, and no host transport can exceed it. Bulk measured 37.7 KiB/s, which is 87% of that ceiling; HID measured 23.2 KiB/s, or 53%. So the bulk plane recovered roughly two thirds of the gap between HID and the physical limit and then ran out of room. The bus was already the constraint, so there was very little left for a transport change to win — which is exactly the reasoning behind I2cTransport.AUTO keeping the ordinary route below the HID ceiling.

Corrected 2026-09-04: this section previously described all three of its figures as taken “at a 1024-byte payload”, and reasoned from that to the 87%. The I2C row was measured at 32768 bytes, not 1024, and 87% is the ratio of the bulk throughput to the bus ceiling rather than anything to do with the 1.62x speed-up. The reasoning was sound and the numbers were right; the payload size it rested on was not. The 11.9x row was added at the same time — it is the largest gain on the bench and the page had called the 7.3x row “the real case” without mentioning it. Provenance for every figure: docs_dev/discussion/performance_baseline.md §1.4 and §1.5.

Caution

On I3C, a length that comes back is not a result that came back. A 32767-byte SDR read completes with no error and returns every byte — but past the end of the target’s actual register map those bytes are filler, and on one measured part the whole 32 KB held just 17 distinct byte values. So a large I3C read proves the frame completed and nothing more. If you are validating a large transfer, compare against a chunked equivalent at incremented addresses, the way examples/blocking_api/spi/bulk_flash_read.py does — never against length alone.

Warning

HDR-DDR gains nothing from the plane, and asking for the advertised I3C group ceiling in that mode would be actively dangerous. DDR is capped at 1024 bytes in firmware and in this SDK, and the cap is bus safety rather than buffer size: DDR is measured reliable to 1024 bytes, and above roughly 2048 bytes the target stops answering its dynamic address and stays that way until re-enumeration. So asking for more does not cost you a refused transfer — it costs you your bus. Firmware refuses at the same threshold; the SDK checks first, so no bytes are staged.

The ordinary route already reaches 1024, which is the same number, so the two routes have an identical ceiling for this mode. The bulk entry points exist so the route covers every mode and so that lifting the cap later is not a new API.

Whatever you measure, record which transport produced it. That is what the one INFO line per connection is for, and a throughput number published without it is a number about nothing.

When the plane is unavailable

available reading False is not one condition, and a caller cannot act on it without knowing which. That is why BulkTransportCapability reports the pieces separately:

queried

Whether the negotiation has run at all. False means it has not, which is a different thing from a real no. Distinguishing them is the field’s only job.

host_transport_open

Whether this host claimed the adapter’s vendor USB interface. False is a host problem: no driver bound, no permission, or another process holding it. On Windows the adapter must be bound to WinUSB; on Linux it needs a udev rule (submodules/CosmicSDK/install_udev_rules.sh, run once).

device_supported

Whether the device answered the capability query claiming bulk support at all. False is a device problem: firmware predating the bulk commands.

operations and ceilings

The per-command and per-group halves of the device’s answer.

This is the one capability in the SDK that is not a property of the device alone: the same adapter answers differently on two machines. That is also why it is negotiated lazily and latched per connection rather than captured at open(), and why it is the only capability field whose value comes from asking the device rather than from a table.

Important

device_supported being True is not evidence that the endpoints exist. Firmware computes the supported field of its capability reply as “did I list at least one command group”, and it lists the I2C and SPI groups unconditionally — so the value is effectively hard-wired to 1 on any build carrying the handler. It reads nothing about whether the vendor interface is actually mounted.

The sharp edge: with WebUSB disabled the adapter enumerates a USB configuration with no vendor interface at all, and the capability query goes on reporting support and endpoint numbers that are not on the wire. Individual bulk commands are then refused at dispatch with FW_INTERFACE_NOT_INITIALIZED. Firmware’s own documentation claims the reply reports no support in that case; it does not.

So host_transport_open is the field that actually discriminates, and the two are reported separately because “bind a driver here” and “update the adapter’s firmware” are different fixes. See USB configuration for what disabling WebUSB does.

Caution

Do not diagnose this from a firmware version string. The version is a compile-time constant, and every version the bulk plane has ever shipped under is ambiguous: both 4.4.0 and 4.5.0 name more than one image, and in each case the images differ in exactly this respect. There is no clean version to compare against, so no version test can be right — not a floor, not an equality, not a table. The capability query is the only discriminator, which is the whole reason it exists as a query rather than as a lookup keyed on anything.

bulk is the whole plane, not the SPI part of it

DeviceCapabilities.bulk is the single latch for all three command groups: it carries operations, ceilings and max_payload_for(...) for I2C and I3C exactly as it does for SPI, and the I2C controller, the I3C controller and both target arenas all read the same object, so they cannot disagree about what the device said.

The field was called spi_bulk while SPI was the only command group the negotiation read, which made the name actively misleading once I2C and I3C joined the plane. That rename has happened: bulk is the field, and DeviceCapabilities.spi_bulk is a deprecated alias property that warns and returns the identical object. Because it is an alias rather than a field, dataclasses.replace(capabilities, spi_bulk=...) no longer resolves — pass bulk= instead.

Two fields on the record are still SPI-shaped, for the historical reason the latch itself no longer carries, and are kept exactly in step with the general ones:

  • available is exactly supports(SPI_CONTROLLER_WRITE) and supports(SPI_CONTROLLER_READ), and DeviceCapabilities.supports_spi_bulk is exactly available. Neither says anything about I2C or I3C.

  • max_spi_payload_bytes is exactly max_payload_for(ProtocolInterface.SPI).

DeviceCapabilities.supports_i3c_bulk is the I3C one-liner, and it reports the device’s answer. It can read True on a build whose extension does not bind the six I3C bulk commands, in which case I3cController.transport still answers HID and the two are not in conflict — they are answers to two different questions. capabilities.bulk and the refusal message from a BULK request tell them apart.

The plane is blocking-only

No async class in this package exposes a payload-carrying bulk method, and that is a design decision with a specific cause rather than an unfinished surface.

Member

Blocking

asyncio

SpiController.write

yes

—

SpiController.read

yes

—

SpiController.transport

yes

—

SpiController.max_transfer_bytes

yes

—

I3cController.transport

yes

—

I3cController.max_transfer_bytes

yes

—

I3cController.max_hdr_ddr_transfer_bytes

yes

yes

I2cController.max_transfer_bytes

yes

—

SpiTarget.write_memory

yes

—

SpiTarget.read_memory

yes

—

SpiTarget.max_transfer_bytes

yes

—

I2cTarget.write_memory

yes

—

I2cTarget.read_memory

yes

—

I2cTarget.max_transfer_bytes

yes

—

Six of those fourteen are the payload methods themselves, and they are the rule; the other eight are capability properties that would only have described a route the async side cannot take, which is a much weaker decision — a property answering identically on both surfaces was simply not written. Do not confuse this fourteen with upstream’s fourteen bulk payload methods, which do all have async twins in CosmicAsyncSDK; see Blocking and asyncio. In addition, write, read and all six AsyncI3cController transfer methods exist but take no transport argument — they stay on the ordinary route, at 1024 bytes.

Note what is not on that list. Every target-mode configuration call is mirrored, and so are release, arena_size and wait_for_event on both target interfaces — so an async program can arm a target, learn how big its arena is, watch its bus events and hand the role back. Only moving bytes is blocking-only.

The reason is structural, and it has two legs.

A bulk write pushes its payload from inside the submit. Upstream’s async bulk calls run the payload send within the submit itself, before any reply, against a two-second per-phase budget. Every *_async submit in this package is required to be non-blocking — that is the whole contract of the async facade, and what lets an awaiting coroutine yield rather than pin a thread. A submit that can stall for two seconds would stall the event loop and every other coroutine on it.

A bulk read cannot be correlated, only isolated. The plane is a single unframed byte stream shared by every bulk operation on the connection: a reply announces a length on the HID path, the bytes then arrive on the bulk endpoint with no transaction id, no header and no delimiter, and the transport drains whatever is left once the announced count has been collected. The only thing associating a payload with the command that asked for it is that nothing else was in flight. So two overlapping bulk reads do not queue — the first one’s drain discards the head of the second one’s payload, the second times out, and because a timed-out collection does not drain, the read after that can be prefixed by bytes that were never meant for it. The bytes come back plausible and the status comes back successful, and there is nothing on the endpoint to detect it with.

One bulk transfer therefore owns the connection from submit to drain. An async def that has to hold a connection-exclusive lock across its own await is a blocking call with a coroutine’s syntax, and it would be advertising concurrency the transport cannot provide. Nor does an executor help: it would serialise the collections, and the damage is done by the submits overlapping.

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. Note that I2C’s and I3C’s async transfer methods did ship in that narrowed shape, with the transport argument dropped from the signature to say so, so this is a judgement about the SPI pair rather than a rule the whole package follows — Blocking and asyncio reconciles the two. The honest asyncio shape, if you want one, is loop.run_in_executor around the blocking call.

The argument in full is docs_dev/designs/async-bulk-transfer-shape.md.

Warning

One adapter has one bulk plane. The vendor interface is claimed exclusively by whichever handle opened it first, so a Device and an AsyncDevice on the same adapter cannot both use it. Choose one handle style per adapter. In particular, do not reach for a second blocking handle to do the arena traffic for an async target on the same unit.

Everything that is not payload-carrying is mirrored normally. The async target classes get configuration in full — initialize, bring_up, configure_eeprom, configure_flash and the rest — and wait_for_event observes the bus. What they do not get is memory access. Bring the target up from the async API, watch its events there, and do arena traffic from a blocking Device; or do the whole thing blocking.

Examples

examples/blocking_api/spi/transport_selection.py

What the negotiation decided on this host and this device, and what made it decide so. Needs no peripheral, no wiring, no second adapter and no bulk driver — the content of the report is the result, not the exit code. The one example that configures logging.

examples/blocking_api/spi/bulk_flash_read.py

A 16 KB SPI flash read in one call, then the same 16384 bytes as sixteen 1024-byte reads at incremented addresses, compared byte for byte. Needs a SPI NOR flash and a host holding the vendor interface. The comparison rather than a length check is the point: a length check passes on a transfer that repeated one chunk sixteen times.

examples/blocking_api/i2c/target_arena_bulk.py

16384 bytes host-to-arena and back, byte-exact and at an address the device honours. Read its first paragraph: nothing in it happens on the I2C wire. It is a bulk-plane example that uses target mode only because the arena is reachable no other way.

examples/blocking_api/spi/target_flash_driven.py

What makes bulk_flash_read.py runnable without buying a flash chip: a second adapter emulates the flash part, with content you chose.

Testing code that uses it

A FakeAdapter reports no bulk plane, which is what most real hosts report and therefore the branch your code will actually take. To exercise the other branch — a host that claimed the interface talking to a device that advertises bulk — describe the pair with BulkProfile. Its two flags are the two independent facts this page keeps separating: transport_open is whether this host claimed the vendor interface, and webusb_enabled is whether the interface is in the device’s configuration descriptor at all — which is how you reproduce the trap above, a device that answers the capability query saying it supports bulk while its endpoints are not on the wire. See Testing without hardware.

Reference