Target mode: I2C and SPI

In target mode the adapter stops driving a bus and starts answering one. An external controller issues reads and writes; the adapter services them out of a memory arena, and your program preloads that arena and reads back what the controller left in it.

Three protocols have a target role. I3C’s is covered in the I3C guide, because almost everything about it differs from these two — including, importantly, the parts that look the same. This page is I2C and SPI, which are the same shape as each other.

Danger

A target bring-up takes the protocol’s role and the device’s shared staging arena with it — and it is reversible in one call. I2cTarget.release and SpiTarget.release hand the role back in-session, with no reboot and no fresh Device. Read The role you are holding before calling any initialize or bring_up on this page anyway, because three things still cost you: a release frees the arena along with the role, the order in which two roles are released matters, and a process killed before its cleanup leaves the adapter armed for whoever opens it next. Getting the interface object is free; the bring-up is the act that takes something, and release() is what gives it back — put it in a finally.

The role you are holding

Each protocol’s peripheral holds exactly one of three states — idle, controller, or target — and a bring-up in either role refuses while the other holds it. Firmware reports that as FW_I2C_ROLE_CONFLICT or FW_SPI_ROLE_CONFLICT, which is a different status from FW_INTERFACE_ALREADY_INITIALIZED: the latter means the same role is already up, and is the ordinary second-run case that bring_up absorbs. A role conflict is never absorbed, by rule — a BringUpResult claiming the target is up while the adapter drives the bus as a controller would be a lie no caller could detect.

Three consequences, in the order a program hits them.

On one adapter, a target and an ordinary controller of the same protocol cannot be up at the same time, in either order. The target bring-up refuses while any role is up, and once it holds the role the controller bring-up refuses in turn. What that costs is one extra call rather than the power cycle: a release() between the two lets one program use both, in either order.

For the I2C target direction the exclusivity is per device, not per bus: i2cTargetInit refuses when a role is held on this bus or when any role is up on the other one, so “controller on A, then target on B” is refused on a Pulsar. A controller bring-up tests only its own bus, so the reverse order is not symmetric — and neither is the release. i2cDeinit(N) clears bus N and nothing else, so freeing a two-bus adapter enough for a target bring-up takes a release per bus, each through that bus’s own interface:

dev.i2c_target(bus=I2cBus.A).release()
dev.i2c_target(bus=I2cBus.B).release()

Bench-confirmed on a Pulsar: FW_I2C_ROLE_CONFLICT with a controller up on bus B and bus A released, and a clean target bring-up once both are released. A controller bring-up needs less, because it tests only its own bus.

One target protocol at a time, across protocols — not one per power cycle. The arena a target serves from is the device’s shared bulk staging pool, claimed while a target is armed. So a program that arms an I2C target cannot also arm a SPI one; the second answers FW_SPI_TARGET_STAGING_UNAVAILABLE, and the reverse order answers FW_I2C_TARGET_STAGING_UNAVAILABLE. Both teardowns hand the pool back, which turns that from a verdict for the power cycle into ordinary contention: release the first, arm the second. Measured on silicon in both directions — an I2C target released and a SPI target then armed and driven through a full 16384-byte arena round trip, and the same the other way round.

This is why examples/blocking_api/i2c/target_eeprom_driven.py and examples/blocking_api/spi/target_flash_driven.py cannot both be armed at once. Each hands back in a finally every role the SDK can release — the SPI pairing is the one that cannot release both ends, for the rev-B reason below — so the two do run one after the other in a single session.

Caution

Release SPI before I2C. With a SPI target holding the arena the I2C target path does not refuse — it times out, and i2c_deinit on that bus then times out too, until the SPI role is released. Measured, and the reason the examples that touch both protocols release the arena holder first.

The role is releasable, in one call — and this page said the opposite until 2026-09-04. Six release() methods ship: I2cTarget.release, SpiTarget.release, both async twins (release, release), and SpiController.release with release. Each sends the command the device has always accepted: firmware implements and dispatches I2C_DEINIT (0x0801) and SPI_DEINIT (0x0C01), and each releases the peripheral role and the shared staging arena, after which the other role initialises normally. The correct statement of the constraint, at both levels, is therefore “one role at a time, released explicitly” — not “one role per power cycle”.

A role conflict is consequently recoverable: release the conflicting role and retry. It is still never absorbed, for the reason above — and the release that would clear it tears down whatever the other role was doing, which is a decision only the caller can take.

A release is role-agnostic. Firmware dispatches on the bus’s (or the peripheral’s) own stored role and tears down whatever it finds, so dev.i2c_target(bus=...).release() releases whichever role that bus holds, a controller included — bench-confirmed: it tore a controller down, and the bus re-armed afterwards. That also makes it the supported way to hand a shared bus from one adapter to the other. The methods live on the target classes because that is where the need arises, not because the role has to be a target’s; building a target interface issues no commands, so constructing one purely to release a controller is free.

I2cController deliberately has no release(), and that absence is an argued one rather than a gap. Device.i2c_target carries no revision gate, so I2cTarget.release is reachable on every unit that has the bus and already serves both of that bus’s roles. SpiController has one for the mirror-image reason: Device.spi_target() refuses a rev-B Supernova outright (see below), so on the pairing that revision forces there would otherwise be no way from Python to hand back a SPI role the device would happily have released. The ergonomics of the I2C side are the price: a caller releasing a controller names the target accessor to do it.

Three things a release does not do, each of which matters more now that the role is cheap to hand back:

  • It does not preserve the arena. arena_size goes back to None, the remembered model with it, and everything staged in the arena is gone — the teardown hands the shared staging pool back, which is exactly what lets the other protocol arm. Read out what you need first, and report no arena figures after.

  • It does not pretend to be idempotent. A bus or peripheral holding no role answers FW_INTERFACE_NOT_INITIALIZED rather than succeeding quietly, matching Uart.deinit; a caller who wants an idempotent release catches that one code and nothing else.

  • It does not always succeed. Firmware predating the dispatch answers FW_UNSUPPORTED_COMMAND, and no version string distinguishes the two builds: FIRMWARE_VERSION_STRING is a compile-time constant, so a build that dispatches the command and one that does not both report the same version. On such a unit Device.reboot really is the only release, and after it the handle is TERMINAL and a fresh Device must be opened.

And a releasable role is not a consequence-free one. A process killed before its cleanup — Ctrl-C at the wrong moment, a runner killing a child on a timeout — leaves the adapter armed with nothing on the wire to explain it to whoever opens it next.

Note

Release through the object whose role it is. Because the command is role-agnostic, a release issued through one interface tears down a role another object may still believe it holds: a SpiTarget handle kept across a SpiController.release goes on reporting the arena_size it was told, with event capture armed for events that can no longer arrive, and the same hazard runs the other way on I2C, where releasing through the target interface can tear down a controller some other object is driving. Only the object that made the call clears its own state.

Note

What this section said until 2026-09-04, kept because the reasoning was load-bearing. It was headed “The role is not releasable through this SDK” and argued: “no revision of CosmicSDK wraps either command. A search of its headers for deinit finds only uartDeinit. So there is nothing for a release() method on these classes to call, and from Python the role really is held for the session. Device.reboot is the only lever a Python caller has.” The firmware half of the old section — quoted almost unchanged above — was already right; the SDK half was a true statement about an older submodule pin and is now false. CosmicSDK PR 247 added i2cDeinit(busId) and spiDeinit(), this SDK binds both, and the six release() methods send them. It is recorded rather than deleted because that reasoning is why the target-mode examples are gated behind an opt-in variable, and why any program written before this date plans around a reboot it no longer needs. The gate is still there, on a new footing — see Examples for the variable’s name and what it now acknowledges.

The stale sentence upstream is the mirror image, and a reader will meet it: CosmicSDK’s FW_I2C_ROLE_CONFLICT and FW_SPI_ROLE_CONFLICT comments still say “Reset the device to release the role”. Firmware is authoritative, and firmware has dispatched the release since its own PR #67. Release the conflicting role and retry.

Warning

The first transfer after a role release is not proven byte-clean. Firmware carries an open issue on a first-transfer byte skew reachable only through controller → release → target, and the candidate fix is not confirmed closed. This is firmware-source reading rather than a bench result, and both SpiController.release and SpiTarget.release make that order reachable. If a role switch is on your critical path, check the first transfer after it specifically rather than the run as a whole.

bring_up() exists here, unlike for the I3C target

I2cTarget.bring_up and SpiTarget.bring_up are the recommended entry points, on both the blocking classes and their asyncio twins. I3cTarget has none. That asymmetry is design, and the reason is the exact inverse of the I3C one — worth stating, because “target mode has no bring-up” was true of this SDK until these two landed.

i2cTargetInit and spiTargetInit are not idempotent: each documents FW_INTERFACE_ALREADY_INITIALIZED as a return value and points at its SetParameters sibling as the idempotent way to re-run configuration. An I2C or SPI interface also stays initialised across a close()/reopen cycle, so the reconfigure arm runs on the second run of any script — it is live code on the common path. i3cTargetInit documents the opposite of itself: it re-initialises the peripheral unconditionally and never reports that status, so there is nothing for a composite to absorb and a bring_up there would be initialize under another name with an unreachable fallback arm.

Two details of these two bring_ups that differ from the four controller ones:

  • Neither absorbs a refused reconfigure, because there is no status to absorb. Both SetParameters calls document success, timeout and disconnect and nothing else, and no capability gates target reconfiguration. So there is no except clause on the second call and settings_applied is always True. Copying the controllers’ refused-status filter here “for symmetry” would invent a branch with no status to put in it.

  • already_initialized is the field that varies, and it is the one a re-runnable script cares about. True is the normal reading on any second run.

  • bring_up is the only single call that always populates arena_size. A bare initialize that raised FW_INTERFACE_ALREADY_INITIALIZED leaves it unset, because upstream leaves memorySize at 0 on that path; configure reports it.

SPI target mode

from pycosmicsdk import (
    Device, SpiBitOrder, SpiChipSelect, SpiChipSelectPolarity, SpiMode,
    SpiTargetEngine, SpiTargetModel,
)

with Device.open() as dev:
    target = dev.spi_target()
    target.bring_up(
        mode=SpiMode.MODE_0,
        bit_order=SpiBitOrder.MSB,
        chip_select=SpiChipSelect.CS_0,
        chip_select_polarity=SpiChipSelectPolarity.ACTIVE_LOW,
        model=SpiTargetModel.FLASH,
        engine=SpiTargetEngine.DMA,
    )
    print(f"arena: {target.arena_size} bytes, {target.max_transfer_bytes} per call")
    target.write_memory(0, b"\xa5" * 4096)

Device.spi_target() is the accessor; it is cached, and getting the object does no I/O.

Three differences from SpiController, each the difference between the two roles rather than a gap to be tidied up later:

  • No frequency_hz and no data_width. A target neither generates the clock nor chooses the frame width; the external controller does both. mode and bit_order remain, because the target has to agree with the controller about them.

  • The arena is bulk-only. There is no HID path for SpiTarget.write_memory or SpiTarget.read_memory, so initialize refuses outright on a host that has not claimed the adapter’s vendor USB interface. transfer is unaffected and needs no bulk plane at all.

  • There is no add_register_region(). SpiTargetModel has a REGISTER_MAP member and firmware has a command code for the region table, but no CosmicSDK revision wraps it and upstream lists it under known gaps with a reason: SPI’s register-map model is driven in band by the command byte, since SPI has no dedicated read/write address bit like I2C’s. A method raising NotImplementedError would advertise a feature the SDK cannot perform, so there is none. I2cTarget has one because I2C’s wrapper exists — which also means FW_SPI_TARGET_REGION_TABLE_FULL is a status you cannot currently provoke from Python, unlike its I2C counterpart.

SpiTargetModel selects what the arena presents on the bus — MEMORY (flat), FLASH (JEDEC id, page program, write-enable handshake — configure it with SpiTarget.configure_flash) or REGISTER_MAP. SpiTargetEngine picks DMA or ISR servicing.

A rev-B Supernova cannot be a SPI target, and firmware does not say so

Warning

This refusal is ours, not the device’s. SPI_TARGET_INIT applies no hardware-revision check at all — it rejects on four conditions and hwVersion is not among them — so on a rev-B Supernova firmware answers SUCCESS, the adapter arms, and then reports nothing ever again: no chip-select edges, no notifications, an arena that never changes. The limit is electrical and downstream of anything firmware can detect: that revision’s SPI level shifter has its chip-select direction pin fixed as an output, so the board cannot receive an incoming chip select. Revision C fixes it.

guard_spi_target refuses it up front with CapabilityError, because silence indistinguishable from a wiring mistake or an SDK bug is worse than a refusal. Branch on capabilities.spi_target_capable rather than catching:

from pycosmicsdk import Device

with Device.open() as dev:
    if not dev.capabilities.spi_target_capable:
        print("this unit cannot receive a chip select; drive a Pulsar as the target")
    else:
        target = dev.spi_target()   # the guard runs here, before any bring-up
        print(f"can emulate a SPI part, {target.max_transfer_bytes} bytes per arena call")

Note where the refusal lands: the accessor itself is guarded, so a caller who cannot obtain the object cannot write the rest of the program against it. That is strictly better than refusing at bring-up, and it is the same shape Device.i3c() already has for a Pulsar.

Because there is no firmware backstop behind this one — firmware reports success and the hardware stays silent — it is also the one capability field where a False a caller believes is wrong can be overridden: build a modified snapshot with dataclasses.replace. Overriding any other capability field only defers the same refusal to the device.

The workaround that firmware actually validated is the other direction: put a Pulsar in the target role and drive it from the Supernova as the SPI controller. No published firmware measurement has a Supernova in the target role.

I2C target mode

from pycosmicsdk import (
    Device, I2cBus, I2cTargetAddressMode, I2cTargetMemoryLayout, I2cTargetModel,
)

with Device.open() as dev:
    target = dev.i2c_target(bus=I2cBus.A)
    target.bring_up(
        address=0x50,
        address_mode=I2cTargetAddressMode.BIT_7,
        address_width=I2cTargetMemoryLayout.BIT_16,
        model=I2cTargetModel.EEPROM,
    )
    target.write_memory(0, bytes(range(256)))

Device.i2c_target() is the accessor, and it is per bus because i2cTargetInit takes a bus id as its first parameter — forced by the upstream surface rather than chosen. Targets are cached under a key distinct from Device.i2c()’s, so one bus’s target and controller interfaces are two objects rather than one.

I2cTargetModel selects MEMORY (flat), EEPROM (24Cxx behaviour — page size, write cycle, busy behaviour, erased-value fill and a write-protect window, via I2cTarget.configure_eeprom) or REGISTER_MAP (regions declared with I2cTarget.add_register_region, each with an I2cTargetRegisterAccess mode and a 1- or 2-byte width). I2cTarget.erase clears a page or the whole arena.

A model-specific command called against the wrong active model is refused Python-side before anything is sent, pre-empting FW_I2C_TARGET_WRONG_MODEL — but only when this object chose the model. An adapter another process left in target mode has a model this object cannot know, and the guard fails open there rather than breaking the re-run path.

Three ways this differs from I3cTarget, each of which will bite a reader who assumes the two are the same shape:

  • write_memory’s address is a byte offset here, where I3cTarget’s is a register index that firmware multiplies by the layout’s register size. Same name, same type, different unit.

  • address_width scales nothing. It is upstream’s memoryLayout, and it selects how many address bytes the external controller sends on the bus: I2cTargetMemoryLayout.BIT_8 confines the bus-visible window to arena bytes 0–255, BIT_16 exposes the whole arena. The argument was renamed from upstream’s memoryLayout deliberately, because memory_layout would collide in a reader’s head with the I3C concept that does scale.

  • The arena is bulk-only, so initialize can refuse on a host that I3cTarget.initialize would have served.

Addresses are range-checked before the wire: 7-bit targets are 0x08–0x77 (the ends are the I2C reserved address blocks) and 10-bit targets 0x000–0x3FF.

The arena

The arena is drawn from the device’s shared bulk staging pool: 32 KB on a Supernova, 16 KB on a Pulsar — but read it, do not assume it. SpiTarget.arena_size and I2cTarget.arena_size report what the device said, and are None until this object brought the interface up. That None is fail-open on purpose: an adapter another process left in target mode has an arena whose size this object cannot know, and write_memory then bounds the call on the transport ceiling alone rather than refusing.

SpiTarget.max_transfer_bytes and I2cTarget.max_transfer_bytes are min(arena_size, advertised ceiling) — the pair, never a compile-time constant, because the advertised ceiling differs between products and a host that assumes one product’s number gets its oversized requests refused on another. They read 0 when the arena is unreachable on this connection, which makes either one the one-call answer to “can this target’s memory be touched at all”. On a Pulsar a 16 KB write fills the arena exactly at offset 0 and overruns it at any other offset.

Two bounds are checked, with two different failure modes, which is why they are reported apart: past the arena end firmware clamps and partially completes rather than rejecting, while above the transport ceiling it is a flat refusal.

There is no HID fallback, and that is why bring-up gates on it

The two arena commands move their payload on the vendor bulk endpoint and have no HID variant. Everything else — initialize, configure, configure_eeprom, configure_flash, erase, add_register_region — is not bulk-gated. So on a host that cannot claim the vendor interface, a target arms successfully, services the bus autonomously, and can neither be seeded nor read back: live, but blind and unseedable. That is materially worse than the SPI controller’s graceful HID fallback, and the two must not read the same.

Both initialize and bring_up therefore run the bulk negotiation and refuse up front with CapabilityError, rather than letting you discover it two calls later at the first write_memory. Both arena operations are required rather than either: support is advertised per command, so a device could offer half the pair, and a target that can be seeded but not read back is one nobody can verify.

require_arena_access=False serves the narrow real case — a bus-only target the host never touches after arming — explicitly, instead of making everyone else pay for it. Every initialize and bring_up on both target classes takes it, asyncio twins included — theirs gating the narrower check described under asyncio.

See The bulk transfer plane for how to tell why the plane is unavailable: host_transport_open and device_supported are reported separately because “bind a driver here” and “update the adapter’s firmware” are different fixes.

Bus events

While a target is armed, firmware pushes a notification for each transaction the external controller performed. For I2C and SPI these are metadata only, and the shape of that metadata is where the traps are.

I2C target

SPI target

I3C target

Pull API

I2cTarget.wait_for_event

SpiTarget.wait_for_event

I3cTarget.wait_for_event

Record it returns

I2cTargetBusEvent

SpiTargetBusEvent

I3cTargetBusEvent

Push API

I2cTargetBusEventNotification

SpiTargetBusEventNotification

I3cTargetBusEventNotification

Carries the transferred bytes

no — the notification’s payload is the raw frame

no — same

yes, in payload

Second field

target_address — the adapter’s own configured address

chip_select — which CS line framed it

target_address — the adapter’s own dynamic address

memory_address

real — the arena byte offset the phase started at

always 0

register index

Events per bus transaction

one — one per transfer phase, all flushed at the STOP, and an ordinary transaction is one phase. A non_stop transfer emits none until something issues the STOP, then both phases at once

one — on chip-select de-assert, and only if a byte moved

one per transfer complete, CCC handled, or dynamic-address change

Kinds

I2cTargetBusEventKind: WRITE_TRANSFER, READ_TRANSFER

SpiTargetBusEventKind: TRANSACTION_COMPLETE, one member

I3cTargetBusEventKind: write, read, CCC, dynamic-address change

Caution

A non_stop I2C transfer reports nothing until something closes the bus. Firmware emits one I2C event per transfer phase and flushes every phase at the STOP. An ordinary write or read is a single phase, so one completed transaction pops wait_for_event exactly once — measured on silicon across writes and reads from 0 to 1024 bytes, with no chunking at any size. A combined read’s sub-address write is absorbed into establishing memory_address rather than being a phase of its own, so read(addr, n, subaddress=...) yields one READ_TRANSFER and no WRITE_TRANSFER. An address-only transaction — a scan the target ACKs, or a bare write(addr, b"") — emits nothing at all, because an event needs the memory-address phase.

So the count matches the SPI target’s, and a loop written against the SPI shape counts correctly on I2C. The hazard is the other direction: a non_stop=True transfer holds the bus with a repeated START and emits zero events while it holds it — measured silent at 0.5 s, 1.0 s and 3.5 s, with wait_for_event(timeout=0.5) raising CosmicTimeoutError throughout — so a caller that writes with non_stop=True and then waits for its event blocks until unrelated code issues the STOP. Both phases then arrive together at that STOP, which is the one case where a single controller-level operation yields two events.

This guide said the reverse until 2026-09-04: that firmware emits at each phase completion and again at STOP, so one transaction produces several, and that an SPI-shaped loop would over-count. That was firmware-source reading and never a bench result; tier G9 of tests/hardware/test_tier_g_target_mode_and_storage.py refutes it in both directions — over-predicting the count for a normal transaction and under-predicting it for a held bus. It is corrected rather than deleted because the advice it gave (count phases, do not trust one event per transaction) sent readers hunting a bug that does not exist.

What genuinely does differ between the two protocols is not the count but the fields — payload_length and memory_address, below.

Warning

payload on the I2C and SPI target notifications is the whole raw frame, not the transferred bytes. Neither class defines a payload of its own, so the name resolves to the notification base’s, which the SDK fills from the entire packet. Only the I3C event shadows it with the bytes that moved, because it is the only one firmware appends data to.

And there are two unrelated length fields, both spelled payloadLength upstream. One sizes that raw frame; the other counts bytes clocked on the bus, and it is the one that becomes payload_length on the typed record. For a well-formed I2C or SPI event len(payload) is a constant twelve — header plus parameters — while payload_length is a count off the wire. So reading payload for the data does not fail loudly: it hands back a frame of a plausible-looking length. What that wire count means is where the two protocols part company, immediately below.

On I2C, payload_length is the field to trust for “how many bytes did that transaction move”. It is meaningful even when the bytes themselves are already gone. To get the bytes, read them out of the arena — and accept that the arena is live, so nothing stops the external controller overwriting that region between the notification and your read. What comes back is what the arena holds now, not necessarily what the notified transaction put there.

On SPI it is not that field, and there is no field that is. SpiTargetBusEvent.payload_length counts the whole framed transaction — every byte clocked between the two chip-select edges, command and address phases included — so a FLASH-model read of N bytes reports N + 4 at the default three-byte address width. The overhead is not constant across models and opcodes, and the event carries neither, so it cannot be subtracted: this SDK computes no data length and offers no read_event_payload. The public alias SpiTargetBusEvent.frame_length is the same number under the name it should have had; prefer it, so that read_memory(offset, event.frame_length) at least reads as the over-read it is. The per-model table of what the count includes is on SpiTargetBusEvent itself. How many bytes a SPI transaction moved is something you know from the protocol you are emulating.

The wait_for_event records sidestep the whole question: I2cTargetBusEvent and SpiTargetBusEvent carry no payload field at all, only payload_length. Prefer them to the raw notification for exactly this reason.

Warning

A SPI target event’s memory_address is always 0, and the I2C one is real. This is the sharpest difference between the two events, and it decides whether the obvious two-line idiom works.

For I2C it does. Firmware fills the field from the slave state’s phase start, so these compose:

event = target.wait_for_event(timeout=1.0)
data = target.read_memory(event.memory_address, event.payload_length)

For SPI it does not. The field is meant to be the byte offset the transaction started at, and it is documented that way upstream, but both firmware emit sites pass zero regardless of where the transaction landed — the FLASH and REGISTER_MAP models track their address internally and never report it. Read at that offset and you get arena byte 0. Where a SPI transaction landed is something you know from the protocol you are emulating, not something the event tells you. The field is carried rather than dropped so the record matches the I2C one, and so it starts working on its own if firmware ever populates it.

Note

There is deliberately no read-the-event’s-payload convenience on either class, even for I2C where the offset is real. It would hide a bulk round trip and the staleness race above behind what reads like a field access, and it would fail outright on a host that never claimed the vendor interface — the same host on which initialize already refused. The two-line form above is the same work with the cost visible.

Reading them

SpiTarget.wait_for_event blocks for the next one and hands back a SpiTargetBusEvent. wait_for_event is the async twin. Event capture is armed before the role switch, so a transaction driven the instant the target comes up is queued rather than lost; the queue is never trimmed, so a burst serviced between two calls is delivered in full, oldest first. Always pass a finite timeout — a wait_for_event with none simply hangs forever on a bench with nothing driving the bus. Concurrent waiters are competing consumers: each event goes to exactly one of them, non-deterministically.

I2cTarget.wait_for_event is the I2C equivalent, with the same queueing and timeout semantics — and the deferred-until-STOP caveat above, which is the reason a finite timeout matters even more on I2C. Both async twins have one too.

Either target’s events are also available push-style: subscribe to I2cTargetBusEventNotification or SpiTargetBusEventNotification through Device.subscribe. If you take that route, mind the rule in Notifications about comparing raw int fields rather than constructing an enum inside a where= predicate: the notification’s kind field is a plain int, while the wait_for_event record’s kind is upgraded to an enum member wherever the SDK knows the value.

Note

The bus-event path has no upstream test. Both of upstream’s own hardware tests bypass the bus-event accessors and poll the arena directly, and upstream says so in its known-gaps list. Anyone building a real target-mode application is the first to exercise this path: test it against a real controller before trusting it, and do not assume the I3C target’s behaviour transfers.

asyncio: configuration in full, memory not at all

AsyncI2cTarget and AsyncSpiTarget each mirror their blocking twin’s whole configuration surface — initialize, bring_up, configure, release, arena_size and wait_for_event, plus configure_eeprom, erase and add_register_region for I2C and configure_flash for SPI — and carry no arena methods at all. That is deliberate: one bulk transfer owns the connection from submit to drain, so an async def holding a connection-exclusive lock across its own await would be advertising concurrency the transport cannot provide. The argument in full is under The plane is blocking-only.

Two consequences of that absence:

Member

Blocking

asyncio

I2cTarget.write_memory

yes

—

I2cTarget.read_memory

yes

—

I2cTarget.max_transfer_bytes

yes

—

SpiTarget.write_memory

yes

—

SpiTarget.read_memory

yes

—

SpiTarget.max_transfer_bytes

yes

—

I2cTarget.arena_size

yes

yes

SpiTarget.arena_size

yes

yes

I2cTarget.wait_for_event

yes

yes

SpiTarget.wait_for_event

yes

yes

Note what is not missing, since the last four rows are there to say so. arena_size is mirrored on both, even though the async target init carries no size out-parameter: the figure is on the wire, packed after the generic header on the init response, and this SDK decodes it there. So is wait_for_event. The asyncio surface is therefore not worse at either of the two things a caller needs in order to arm a target and watch it — only at moving bytes.

The async classes gate on the bulk plane too, and more narrowly. Both async initialize methods take the same require_arena_access: bool = True and raise CapabilityError before arming — the trap they close is the same one, since a target armed here services the bus autonomously while staying unseedable from this machine, and the absence of arena methods on this surface makes that harder to notice, not less likely.

What differs is how much of the question each side can ask. The blocking guard runs the full negotiation and checks both halves — this host’s claimed vendor interface and the device’s advertised support for the two arena commands. The async guard checks only host_transport_open, because an async facade cannot answer the device half at all: CosmicAsyncSDK’s getBulkCapabilities is a bare submit whose decode lives in the blocking wrapper, so probing it would read a shape mismatch as absence and refuse every async bring-up. That is the half that discriminates anyway — firmware hard-wires device_supported to True (see The bulk transfer plane) — so the narrower check is not a weaker one in practice. It is narrower in principle, and a device that genuinely advertised half the arena pair would pass it.

And remember that one adapter has one bulk plane, claimed exclusively by whichever handle opened first — so you cannot open a second, blocking Device on the same adapter to do the arena traffic for an async target. Two adapters, or one handle style.

Errors

I2C target failures surface as I2cError: every target-mode status firmware reports for I2C sits in the 0x02xx band. SPI target failures surface as SpiError — which is the only place that class fires, since the 0x03xx band is otherwise empty upstream. Two exceptions in both cases: FW_INTERFACE_ALREADY_INITIALIZED is generic and arrives as FirmwareError, and the bulk-unavailable condition is pre-empted as a CapabilityError before anything is sent.

Field widths, arena bounds, addresses, power-of-two page sizes and region descriptors are all checked Python-side and raise ValidationError with no USB traffic.

Note

A failed outbound arena payload is reported as nothing at all. On the read path firmware replies SUCCESS before it streams the bytes and discards the send result, so FW_*_TARGET_PAYLOAD_TRANSFER_FAILED is a write-path status only. Do not expect it from a read_memory.

Examples

Important

All three skip unless PYCOSMICSDK_EXAMPLES_ALLOW_TARGET_MODE is set, and a run without it exits quietly rather than explaining itself, so a reader who follows the file names below and sees nothing happen is meeting the gate rather than a broken example:

export PYCOSMICSDK_EXAMPLES_ALLOW_TARGET_MODE=1

(PowerShell: $env:PYCOSMICSDK_EXAMPLES_ALLOW_TARGET_MODE = "1".) It is no longer consent for an irreversible act — each example hands the role back in a finally. It acknowledges the two things a release() does not cover: a run killed before its finally leaves the adapter armed, and firmware predating the deinit dispatch refuses the release outright. All three also want PYCOSMICSDK_EXAMPLES_TARGET_SERIAL naming the adapter that takes the role, and the two driven ones PYCOSMICSDK_EXAMPLES_CONTROLLER_SERIAL for the adapter that stays a controller — named rather than guessed, on purpose. Each script’s module docstring carries its own copy.

examples/blocking_api/i2c/target_arena_bulk.py

16 KB through the staging arena, byte-exact and addressably. Its first paragraph is the important one: 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/i2c/target_eeprom_driven.py

Two adapters. B emulates a 24Cxx EEPROM and is preloaded through the arena; A is a plain I2C controller and reads it with 2-byte sub-addressing — exactly the peripheral examples/blocking_api/i2c/blocking.py says it needs. Probe first, switch second: the behavioural rule that matters most here, and the inversion of the three I3C target examples.

examples/blocking_api/spi/target_flash_driven.py

The SPI half. B becomes a FLASH-model target with a JEDEC id you choose; A issues 0x9F and gets it back, then reads the arena out in one call and again in chunks and compares them. This is what makes examples/blocking_api/spi/bulk_flash_read.py runnable without buying a flash chip. Mind the role asymmetry: a rev-B Supernova cannot be the target.

The two driven examples cannot both be armed at once, for the staging-pool reason above — but each hands its roles back in a finally, so they run one after the other in a single session, and a run that ends badly costs one release() rather than a power cycle.

Reference