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_sizegoes back toNone, 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_INITIALIZEDrather than succeeding quietly, matchingUart.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_STRINGis a compile-time constant, so a build that dispatches the command and one that does not both report the same version. On such a unitDevice.rebootreally is the only release, and after it the handle isTERMINALand a freshDevicemust 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
SetParameterscalls document success, timeout and disconnect and nothing else, and no capability gates target reconfiguration. So there is noexceptclause on the second call andsettings_appliedis alwaysTrue. Copying the controllers’ refused-status filter here “for symmetry” would invent a branch with no status to put in it.already_initializedis the field that varies, and it is the one a re-runnable script cares about.Trueis the normal reading on any second run.bring_upis the only single call that always populatesarena_size. A bareinitializethat raisedFW_INTERFACE_ALREADY_INITIALIZEDleaves it unset, because upstream leavesmemorySizeat 0 on that path;configurereports 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_hzand nodata_width. A target neither generates the clock nor chooses the frame width; the external controller does both.modeandbit_orderremain, because the target has to agree with the controller about them.The arena is bulk-only. There is no HID path for
SpiTarget.write_memoryorSpiTarget.read_memory, soinitializerefuses outright on a host that has not claimed the adapter’s vendor USB interface.transferis unaffected and needs no bulk plane at all.There is no
add_register_region().SpiTargetModelhas aREGISTER_MAPmember 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 raisingNotImplementedErrorwould advertise a feature the SDK cannot perform, so there is none.I2cTargethas one because I2C’s wrapper exists — which also meansFW_SPI_TARGET_REGION_TABLE_FULLis 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’saddressis a byte offset here, whereI3cTarget’s is a register index that firmware multiplies by the layout’s register size. Same name, same type, different unit.address_widthscales nothing. It is upstream’smemoryLayout, and it selects how many address bytes the external controller sends on the bus:I2cTargetMemoryLayout.BIT_8confines the bus-visible window to arena bytes 0–255,BIT_16exposes the whole arena. The argument was renamed from upstream’smemoryLayoutdeliberately, becausememory_layoutwould collide in a reader’s head with the I3C concept that does scale.The arena is bulk-only, so
initializecan refuse on a host thatI3cTarget.initializewould 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 |
|||
Record it returns |
|||
Push API |
|||
Carries the transferred bytes |
no — the notification’s |
no — same |
yes, in |
Second field |
|
|
|
|
real — the arena byte offset the phase started at |
always |
register index |
Events per bus transaction |
one — one per transfer phase, all flushed at the STOP, and an ordinary transaction is one phase. A |
one — on chip-select de-assert, and only if a byte moved |
one per transfer complete, CCC handled, or dynamic-address change |
Kinds |
|
|
|
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 |
|---|---|---|
|
yes |
— |
|
yes |
— |
|
yes |
— |
|
yes |
— |
|
yes |
— |
|
yes |
— |
|
yes |
yes |
|
yes |
yes |
|
yes |
yes |
|
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.py16 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.pyTwo 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.pysays 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.pyThe SPI half. B becomes a
FLASH-model target with a JEDEC id you choose; A issues0x9Fand gets it back, then reads the arena out in one call and again in chunks and compares them. This is what makesexamples/blocking_api/spi/bulk_flash_read.pyrunnable 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
pycosmicsdk.SpiTarget/pycosmicsdk.AsyncSpiTarget,pycosmicsdk.SpiTargetModel,pycosmicsdk.SpiTargetEngine,pycosmicsdk.SpiTargetBusEvent,pycosmicsdk.SpiTargetBusEventKindpycosmicsdk.I2cTarget/pycosmicsdk.AsyncI2cTarget,pycosmicsdk.I2cTargetModel,pycosmicsdk.I2cTargetAddressMode,pycosmicsdk.I2cTargetMemoryLayout,pycosmicsdk.I2cTargetRegisterAccess,pycosmicsdk.I2cTargetEraseScope,pycosmicsdk.I2cTargetEepromBusyBehaviorThe bulk transfer plane — the arena’s only transport
I3C — the third target role, and why almost nothing here applies to it