# 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](i3c.md), 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.** {py:obj}`I2cTarget.release ` and {py:obj}`SpiTarget.release ` hand the role back in-session, with no reboot and no fresh {py:obj}`~pycosmicsdk.Device`. Read [The role you are holding](#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 {py:obj}`~pycosmicsdk.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: ```python 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: {py:obj}`I2cTarget.release `, {py:obj}`SpiTarget.release `, both async twins ({py:obj}`~pycosmicsdk.AsyncI2cTarget.release`, {py:obj}`~pycosmicsdk.AsyncSpiTarget.release`), and {py:obj}`SpiController.release ` with {py:obj}`~pycosmicsdk.AsyncSpiController.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. **{py:obj}`~pycosmicsdk.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. {py:obj}`~pycosmicsdk.SpiController` has one for the mirror-image reason: {py:obj}`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 {py:obj}`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 {py:obj}`Device.reboot ` really is the only release, and after it the handle is `TERMINAL` and a fresh {py:obj}`~pycosmicsdk.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 {py:obj}`~pycosmicsdk.SpiTarget` handle kept across a {py:obj}`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](#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 {py:obj}`SpiController.release ` and {py:obj}`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 {py:obj}`I2cTarget.bring_up ` and {py:obj}`SpiTarget.bring_up ` are the recommended entry points, on both the blocking classes and their asyncio twins. {py:obj}`~pycosmicsdk.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_up`s 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 ```python 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) ``` {py:obj}`Device.spi_target() ` is the accessor; it is cached, and getting the object does no I/O. **Three differences from {py:obj}`~pycosmicsdk.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 {py:obj}`SpiTarget.write_memory ` or {py:obj}`SpiTarget.read_memory `, so `initialize` refuses outright on a host that has not claimed the adapter's vendor USB interface. {py:obj}`~pycosmicsdk.SpiController.transfer` is unaffected and needs no bulk plane at all. - **There is no `add_register_region()`.** {py:obj}`~pycosmicsdk.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. {py:obj}`~pycosmicsdk.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. {py:obj}`~pycosmicsdk.SpiTargetModel` selects what the arena presents on the bus — `MEMORY` (flat), `FLASH` (JEDEC id, page program, write-enable handshake — configure it with {py:obj}`SpiTarget.configure_flash `) or `REGISTER_MAP`. {py:obj}`~pycosmicsdk.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 {py:obj}`~pycosmicsdk.CapabilityError`, because silence indistinguishable from a wiring mistake or an SDK bug is worse than a refusal. Branch on {py:obj}`capabilities.spi_target_capable ` rather than catching: ```python 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 {py:obj}`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 ```python 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))) ``` {py:obj}`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 {py:obj}`Device.i2c() `'s, so one bus's target and controller interfaces are two objects rather than one. {py:obj}`~pycosmicsdk.I2cTargetModel` selects `MEMORY` (flat), `EEPROM` (24Cxx behaviour — page size, write cycle, busy behaviour, erased-value fill and a write-protect window, via {py:obj}`I2cTarget.configure_eeprom `) or `REGISTER_MAP` (regions declared with {py:obj}`I2cTarget.add_register_region `, each with an {py:obj}`~pycosmicsdk.I2cTargetRegisterAccess` mode and a 1- or 2-byte width). {py:obj}`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 {py:obj}`~pycosmicsdk.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 {py:obj}`~pycosmicsdk.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 {py:obj}`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. {py:obj}`SpiTarget.arena_size ` and {py:obj}`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. {py:obj}`SpiTarget.max_transfer_bytes ` and {py:obj}`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 {py:obj}`~pycosmicsdk.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](#asyncio-configuration-in-full-memory-not-at-all). See [The bulk transfer plane](bulk.md) 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 | {py:obj}`I2cTarget.wait_for_event ` | {py:obj}`SpiTarget.wait_for_event ` | {py:obj}`I3cTarget.wait_for_event ` | | Record it returns | {py:obj}`~pycosmicsdk.I2cTargetBusEvent` | {py:obj}`~pycosmicsdk.SpiTargetBusEvent` | {py:obj}`~pycosmicsdk.I3cTargetBusEvent` | | Push API | {py:obj}`~pycosmicsdk.I2cTargetBusEventNotification` | {py:obj}`~pycosmicsdk.SpiTargetBusEventNotification` | {py:obj}`~pycosmicsdk.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 | {py:obj}`~pycosmicsdk.I2cTargetBusEventKind`: `WRITE_TRANSFER`, `READ_TRANSFER` | {py:obj}`~pycosmicsdk.SpiTargetBusEventKind`: `TRANSACTION_COMPLETE`, one member | {py:obj}`~pycosmicsdk.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.** {py:obj}`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 {py:obj}`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 {py:obj}`~pycosmicsdk.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: {py:obj}`~pycosmicsdk.I2cTargetBusEvent` and {py:obj}`~pycosmicsdk.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: ```python 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 {py:obj}`SpiTarget.wait_for_event ` blocks for the next one and hands back a {py:obj}`~pycosmicsdk.SpiTargetBusEvent`. {py:obj}`~pycosmicsdk.AsyncSpiTarget.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. {py:obj}`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 {py:obj}`~pycosmicsdk.I2cTargetBusEventNotification` or {py:obj}`~pycosmicsdk.SpiTargetBusEventNotification` through {py:obj}`Device.subscribe `. If you take that route, mind the rule in [Notifications](../reference/notifications.md) 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 {py:obj}`~pycosmicsdk.AsyncI2cTarget` and {py:obj}`~pycosmicsdk.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](bulk.md). 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 {py:obj}`~pycosmicsdk.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](bulk.md)) — 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 {py:obj}`~pycosmicsdk.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 {py:obj}`~pycosmicsdk.I2cError`: every target-mode status firmware reports for I2C sits in the 0x02xx band. SPI target failures surface as {py:obj}`~pycosmicsdk.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 {py:obj}`~pycosmicsdk.FirmwareError`, and the bulk-unavailable condition is pre-empted as a {py:obj}`~pycosmicsdk.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 {py:obj}`~pycosmicsdk.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: ```bash 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 - {py:obj}`pycosmicsdk.SpiTarget` / {py:obj}`pycosmicsdk.AsyncSpiTarget`, {py:obj}`pycosmicsdk.SpiTargetModel`, {py:obj}`pycosmicsdk.SpiTargetEngine`, {py:obj}`pycosmicsdk.SpiTargetBusEvent`, {py:obj}`pycosmicsdk.SpiTargetBusEventKind` - {py:obj}`pycosmicsdk.I2cTarget` / {py:obj}`pycosmicsdk.AsyncI2cTarget`, {py:obj}`pycosmicsdk.I2cTargetModel`, {py:obj}`pycosmicsdk.I2cTargetAddressMode`, {py:obj}`pycosmicsdk.I2cTargetMemoryLayout`, {py:obj}`pycosmicsdk.I2cTargetRegisterAccess`, {py:obj}`pycosmicsdk.I2cTargetEraseScope`, {py:obj}`pycosmicsdk.I2cTargetEepromBusyBehavior` - [The bulk transfer plane](bulk.md) — the arena's only transport - [I3C](i3c.md) — the third target role, and why almost nothing here applies to it