# 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](i2c.md), [SPI](spi.md), [I3C](i3c.md)) 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.** {py:obj}`~pycosmicsdk.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 {py:obj}`~pycosmicsdk.I3cTarget.write_memory` and {py:obj}`~pycosmicsdk.I3cTarget.read_memory` take no `transport` argument and never will, and there is deliberately no I3C target member of {py:obj}`~pycosmicsdk.BulkOperation` — a member for it would be a value that can never be `True`. The five reachable cells are the nine members of {py:obj}`~pycosmicsdk.BulkOperation`, which is the vocabulary {py:obj}`BulkTransportCapability.supports ` takes: | `BulkOperation` member | What it carries | |---|---| | `SPI_CONTROLLER_WRITE`, `SPI_CONTROLLER_READ` | {py:obj}`~pycosmicsdk.SpiController.write` / {py:obj}`~pycosmicsdk.SpiController.read` | | `I2C_CONTROLLER_WRITE`, `I2C_CONTROLLER_READ` | {py:obj}`~pycosmicsdk.I2cController.write` / {py:obj}`~pycosmicsdk.I2cController.read` | | `SPI_TARGET_WRITE_MEMORY`, `SPI_TARGET_READ_MEMORY` | {py:obj}`SpiTarget.write_memory ` / {py:obj}`SpiTarget.read_memory ` | | `I2C_TARGET_WRITE_MEMORY`, `I2C_TARGET_READ_MEMORY` | {py:obj}`I2cTarget.write_memory ` / {py:obj}`I2cTarget.read_memory ` | | `I3C_CONTROLLER_TRANSFER` | all six {py:obj}`~pycosmicsdk.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 {py:obj}`~pycosmicsdk.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 {py:obj}`~pycosmicsdk.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 {py:obj}`~pycosmicsdk.BulkTransportCapability.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: {py:obj}`I2cController.max_transfer_bytes ` and {py:obj}`SpiTarget.max_transfer_bytes ` and their siblings answer it in one call, and {py:obj}`BulkTransportCapability.max_payload_for ` answers it per protocol. ```python 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 {py:obj}`~pycosmicsdk.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=`: {py:obj}`~pycosmicsdk.SpiTransport`, {py:obj}`~pycosmicsdk.I3cTransport` and {py:obj}`~pycosmicsdk.I2cTransport`. All three have the same three members and the same meanings for `BULK` and `HID`: `BULK` : Require the plane. Raises {py:obj}`~pycosmicsdk.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.** {py:obj}`SpiTransport.AUTO ` and {py:obj}`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**: {py:obj}`~pycosmicsdk.I2cController.write` and {py:obj}`~pycosmicsdk.I2cController.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 {py:obj}`~pycosmicsdk.I2cController`**, where SPI and I3C both have one. {py:obj}`SpiController.transport ` and {py:obj}`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 {py:obj}`~pycosmicsdk.I2cController` would have to be a function of a request that has not been made yet. The useful question is answered instead by {py:obj}`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 {py:obj}`~pycosmicsdk.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](lifecycle.md) 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 {py:obj}`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 {py:obj}`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 {py:obj}`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)`. {py:obj}`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 {py:obj}`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](../asyncio.md). In addition, {py:obj}`~pycosmicsdk.AsyncI2cController.write`, {py:obj}`~pycosmicsdk.AsyncI2cController.read` and all six {py:obj}`~pycosmicsdk.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: {py:obj}`~pycosmicsdk.SpiController.write` and {py:obj}`~pycosmicsdk.SpiController.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](../asyncio.md) 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 {py:obj}`~pycosmicsdk.Device` and an {py:obj}`~pycosmicsdk.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 — {py:obj}`~pycosmicsdk.AsyncI2cTarget.initialize`, {py:obj}`~pycosmicsdk.AsyncI2cTarget.bring_up`, {py:obj}`~pycosmicsdk.AsyncI2cTarget.configure_eeprom`, {py:obj}`~pycosmicsdk.AsyncSpiTarget.configure_flash` and the rest — and {py:obj}`~pycosmicsdk.AsyncSpiTarget.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 {py:obj}`~pycosmicsdk.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 {py:obj}`~pycosmicsdk.testing.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 {py:obj}`~pycosmicsdk.testing.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](testing.md). ## Reference - {py:obj}`pycosmicsdk.BulkTransportCapability`, {py:obj}`pycosmicsdk.BulkOperation` - {py:obj}`pycosmicsdk.SpiTransport`, {py:obj}`pycosmicsdk.I3cTransport` - {py:obj}`pycosmicsdk.DeviceCapabilities` - [Target mode](target-mode.md) — the two bulk-only arenas, and the role they cost