I3C

I3C is Supernova-only — the Pulsar has no I3C hardware, so Device.i3c() on a Pulsar raises CapabilityError before anything reaches the bus. examples/blocking_api/i3c/capability_rejection.py demonstrates exactly that.

Three interfaces cover the two bus roles:

I3cController

The adapter as controller. Bus bring-up, dynamic address assignment, and SDR / HDR-DDR / legacy-I2C transfers. From Device.i3c().

I3cCcc

Common command codes, as a sub-interface reached through I3cController.ccc.

I3cTarget

The adapter as target, answering an external controller out of its own register memory. From Device.i3c_target().

Each has an Async twin. Target mode runs on the same hardware, so ProtocolInterface.I3C in DeviceCapabilities.protocols is what gates both.

Ports and voltage

There is no port selector. The bus voltage alone picks the physical connector:

set_voltage(voltage_mv=...)

Effect

800–1199

drives the low-voltage port

1200–3300

drives the high-voltage port

0

target rail off

I3cController.get_bus_voltage returns (hv_mv, lv_mv) — both rails, of which only the powered one reads back anything meaningful, so pair it with use_external_voltage. get_bus_voltage mirrors it; see Blocking and asyncio.

The I3C rails are separate from the multifunction VTARG rail that I2C, SPI, UART and GPIO share.

set_voltage is the one rail setter with no persist argument, and that is upstream’s shape rather than a gap here: firmware offers no way to persist an I3C target voltage across power cycles, so there is nothing for the parameter to select. Its companion use_external_voltage does take persist, because that one upstream does offer. Set the I3C rail on every run.

Bring-up and discovery

from pycosmicsdk import (
    Device, DeviceModel, I2cOpenDrainRate, I3cOpenDrainRate, I3cPushPullRate,
)

with Device.open(model=DeviceModel.SUPERNOVA) as dev:
    i3c = dev.i3c()
    i3c.set_voltage(voltage_mv=3300)
    i3c.bring_up(
        push_pull_rate=I3cPushPullRate.PUSH_PULL_5_MHZ_DC_50,
        open_drain_rate=I3cOpenDrainRate.OPEN_DRAIN_400_KHZ,
        i2c_open_drain_rate=I2cOpenDrainRate.FAST_MODE,
    )
    for target in i3c.init_bus():
        print(hex(target.dynamic_address), target.pid.hex())

bring_up is the re-runnable bring-up and the one to reach for: it tries initialize, falls back to configure when the bus is already up, and returns a BringUpResult. On return the bus is up and usable. The contract is shared with I2C, UART and SPI and is explained once under Bring-up is bring_up().

Branch on settings_applied here more readily than elsewhere: the rate pair below is what every timing observation on the bus is relative to, so a bus silently left at an earlier run’s rates is a wrong measurement rather than a slow one.

result = i3c.bring_up(
    push_pull_rate=I3cPushPullRate.PUSH_PULL_5_MHZ_DC_50,
    open_drain_rate=I3cOpenDrainRate.OPEN_DRAIN_400_KHZ,
    i2c_open_drain_rate=I2cOpenDrainRate.FAST_MODE,
)
if not result.settings_applied:
    raise result.refusal

The rate ladders are long — I3cPushPullRate alone has 22 members, each naming a frequency and a duty cycle. The full lists are in Enums.

There is a fourth bus parameter the snippets above leave at its default. drive_strength is an I3cDriveStrength — STANDARD or FAST, defaulting to FAST — and it is accepted by initialize, configure and bring_up alike. Like the three rates it only ever travels device-ward: upstream declares no call that reads the bus configuration back, so there is nothing to compare a member against and it is bound as a plain enum.Enum rather than an IntEnum. Pass the member, never 0 or 1.

Underneath, initialize is not idempotent: an already-initialised bus reports FW_INTERFACE_ALREADY_INITIALIZED, and the bus stays initialised across reconnects. configure re-configures a live bus. examples/helpers.py’s i3c_bring_up() delegates to bring_up and adds the examples’ console lines; it no longer carries a fallback of its own.

init_bus issues RSTDAA then ENTDAA and returns the resulting device table as I3cTargetInfo entries. Pass targets= to pre-seed known devices so they keep deterministic dynamic addresses; anything else on the bus is assigned by the firmware.

Warning

A seed entry that matches no target on the bus is discarded in silence. When seeding, pid, bcr and dcr are match keys, not values you are storing: firmware gives the entry’s dynamic_address to the target that matches all three. Get one byte of the pid wrong and the entry simply does not apply — that target is auto-assigned as if you had passed nothing, no error is raised, and its returned record comes back built purely from discovery, with max_write_length, max_read_length and ibi_payload_size all zero. Those three zeros are the only signal you get.

Read the PID off the bus with getpid and seed from that, never from a literal transcribed out of a datasheet. If the addresses have to be deterministic, check them: compare the returned dynamic_address against what you asked for rather than assuming the seed took.

Construct I3cTargetInfo by keyword. Its positional order puts max_read_length before max_write_length, which is the reverse of the order those two fields occupy on the wire and the reverse of the order initialize takes them in. Swapping them positionally produces two plausible numbers and no error at all.

get_device_table re-reads the cached table without putting traffic on the bus, and reset_bus clears it.

Transfers

Three families, all taking bytes and an I3cTransferFlags value:

  • sdr_write / sdr_read — normal I3C SDR.

  • hdr_ddr_write / hdr_ddr_read — HDR-DDR, which also takes a command byte and has its own length constraints.

  • legacy_i2c_write / legacy_i2c_read — I2C-legacy targets on the I3C bus. This is how an I2C or SMBus device is reached through the I3C port, next to I3C targets, at the i2c_open_drain_rate given to bring_up. The I3C engine does not wait for a target that stretches SCL: call enable_i2c_clock_stretching first (firmware 4.6.0+), or use the I2C port. examples/blocking_api/i3c/legacy_i2c_target.py shows the full sequence.

Note that the default flags differ between the SDR and legacy-I2C pairs; autodoc renders the real defaults on each method.

target_reset_pattern and hdr_exit_pattern emit the corresponding bus patterns.

Which transport a transfer takes

All six take a transport= argument typed I3cTransport, and I3cController.transport reports which route they will use on this connection — before the transfer rather than as after-the-fact state, because the negotiation is latched per connection. AUTO (the default) prefers the adapter’s vendor bulk USB endpoint where this host and device can reach it, BULK requires it, HID never negotiates.

Every design note in this repository says I3C has no bulk path at all. That is retracted for the controller role, in all three transfer modes. The device implements one bulk command and CosmicSDK wraps it six times — SDR, HDR-DDR and legacy-I2C, write and read — so there is exactly one capability bit for all six methods, and capabilities.supports_i3c_bulk() is the single answer. It is Supernova only: a Pulsar has no I3C peripheral and advertises no I3C command group.

Warning

The three modes do not benefit equally, and assuming a uniform speedup will mislead you.

  • SDR is the mode with a real gain. The ordinary route carries its bytes inside the request and cannot exceed 1024; the bulk route reaches what the device advertised for its I3C group. On a write the register sub-address shares the frame with the payload, so the usable payload is the ceiling minus len(subaddress); a read has no such deduction.

  • HDR-DDR gains nothing today, and asking for the advertised ceiling in this mode would be actively dangerous. DDR is capped at 1024 bytes on both routes, in firmware and in this SDK, and the cap is bus safety rather than buffer size: above roughly 2048 bytes the target stops answering its dynamic address and stays that way until re-enumeration. So asking for more does not cost you a refused transfer — it costs you your bus. The ordinary route already reaches 1024, which is the same number. The bulk entry points exist so the route covers every mode and so lifting the cap later is not a new API.

  • Legacy I2C on an I3C bus is bounded by its own open-drain bus rate long before the transport matters, so expect much less than from SDR.

Important

The I3C ceiling is 32767, not the 32768-byte staging arena, and the odd number is the point. It is a hardware frame limit rather than a buffer size: the I3C peripheral’s transaction-length field is 15 bits. So unlike I2C and SPI, which reach their model’s whole arena, I3C stops one byte short of it — and a request for 32768 is refused with a ValidationError that says so rather than being a bug.

Firmware advertises and enforces the same 32767, so this is not a case of the host guarding against an over-advertising device. Both ends are avoiding a measured failure: a 32768-byte frame reports success with the last byte never put on the bus, and leaves the I3C engine non-idle so the following transfer fails too. Read max_payload_for rather than hard-coding either number.

The six AsyncI3cController transfer methods exist but take no transport argument, and AsyncI3cController has no transport property: the bulk route is blocking-only across this whole package, for structural reasons set out under The plane is blocking-only. The async methods stay on the ordinary route at 1024 bytes.

Nothing is ever split: a payload over the active ceiling raises ValidationError naming that ceiling. The selection is logged once per connection at INFO on the pycosmicsdk.i3c logger. Full account in The bulk transfer plane.

SMBus interface reset and the SDA pull-up

hold_scl_low drives SCL low for 1 us to 1 s, then releases it (the SMBus interface reset, T2wrst) and returns the hold the firmware measured. It raises FW_BUS_TIMEOUT if SCL is still low 35 ms after the release. set_sda_pull_up sets the board’s 1 kOhm SDA pull-up to I3cSdaPullUp AUTO (default: the I3C engine drives it), OFF or ON (while clock stretching is on; with it off the I3C engine drives the pull-up). SCL has no on-board pull-up; fit an external one.

i3c.set_sda_pull_up(I3cSdaPullUp.ON)
held_us = i3c.hold_scl_low(35_000)

Firmware without either command raises FirmwareError (FW_UNSUPPORTED_COMMAND).

Common command codes

I3cController.ccc exposes roughly forty named CCCs — getpid, getbcr, getdcr, getstatus, getcaps, getmxds, getxtime, the setmwl/setmrl and enec/disec broadcast/direct pairs, the entas* activity states, rstdaa, entdaa, setdasa, setaasa, setnewda, setgrpa_direct, rstact_*, and so on. Each is one command; the wrappers decode the response into a typed return (getbcr gives an int, getmrl gives an I3cGetMrlResult, getpid gives bytes).

AsyncI3cCcc.transfer is the raw primitive the named wrappers are built on, and the only route to an opcode this SDK does not name. It has no blocking twin — upstream declares i3cCccTransfer on its async pin only. See Blocking and asyncio.

The three enums the CCC surface takes

Most CCC wrappers take an address and nothing else. Three of them take a typed value, and each one is worth knowing before you need it.

I3cEvent — the ENEC / DISEC mask.

Which bus events a target may raise: IBI (in-band interrupt, 1), CR (controller-role request, 2) and HJ (hot-join, 8). It is the events argument of enec_direct, enec_broadcast and their disec twins. It is the one enum here that is bitwise-combinable — an enum.IntFlag, so ccc.enec_direct(0x08, I3cEvent.IBI | I3cEvent.HJ) arms both on the target at 0x08 in one command. Bound any other way, that | would be a TypeError and only one event could be enabled per CCC.

I3cTransferDirection — which way a CCC frame moves.

WRITE or READ, the direction argument of rstact_direct and of AsyncI3cCcc.transfer. Like the bus parameters it travels device-ward only: nothing decodes a direction back out of a response or a notification.

I3cTargetResetDefByte — the RSTACT defining byte.

Which reset action to arm on a target, or which recovery value to ask it for — the def_byte argument of rstact_broadcast and rstact_direct. Nine members: the four NO_RESET / RESET_* actions, VIRTUAL_TARGET_DETECT, and the four RETURN_* queries that read a value back instead of arming anything.

Warning

Neither of the last two is int-comparable, and that is deliberate. Both are bound as plain enum.Enums, so some_int == I3cTransferDirection.READ is always False — not an oversight to work around, but the point.

I3cTransferDirection’s WRITE/READ are 0/1, the very same values as I3cTargetBusEventKind’s WRITE_TRANSFER/READ_TRANSFER — and that enum is arithmetic, because I3cTargetBusEventNotification carries kind as a raw int. The two spell the same two words for two unrelated domains, so the slip is an easy one to make:

from pycosmicsdk import I3cTargetBusEventKind, I3cTargetBusEventNotification, I3cTransferDirection

# Wrong enum: silently False forever, so the subscription never fires. It would
# be True by numeric coincidence if this enum were arithmetic -- which is why
# it is not.
bad = dev.subscribe(
    I3cTargetBusEventNotification,
    lambda n: print(n.memory_address),
    where=lambda n: n.kind == I3cTransferDirection.READ,
)

# Right enum, and the comparison the arithmetic binding exists for.
good = dev.subscribe(
    I3cTargetBusEventNotification,
    lambda n: print(n.memory_address),
    where=lambda n: n.kind == I3cTargetBusEventKind.READ_TRANSFER,
)

I3cTargetResetDefByte is the same story with a sharper edge. rstact_direct does return int | None — upstream’s only CCC out-parameter — but the byte it returns is not a defining byte. Upstream documents it as the retrieved reset recovery timing value: a duration in the I3C specification’s units for the three RETURN_TIME_* queries, and a 0/1 indication for RETURN_VIRTUAL_TARGET_INDICATION. The recovery times this enum would need members for are not members of it, so there is nothing here to compare a returned byte against. Were it arithmetic, comparing that 1 against RESET_I3C_PERIPHERAL — which is also 0x01 — would quietly come out True for every target that reports itself virtual.

Target mode

I3cTarget switches the adapter into the target role. initialize takes the identity the adapter will present (pid, bcr, dcr, static_address) plus the shape of its register memory (memory_layout, max_write_length, max_read_length).

write_memory and read_memory reach that local arena from the host side, and wait_for_event blocks for the next I3cTargetBusEvent — what a controller on the bus did. I3cTargetBusEvent.kind is an I3cTargetBusEventKind member when the SDK knows the event, and a plain int carrying the raw firmware value when it does not, so a newer firmware’s event still arrives instead of raising.

Important

The I3C target has no bulk path, and that half of “I3C has no bulk” survives — as a design choice rather than an omission. Its arena is a private 1024-byte buffer, not the shared bulk staging pool the I2C and SPI target arenas come from, and its memory commands are HID-carried and correspondingly absent from the I3C command mask — while the I2C and SPI target-memory commands were added to theirs. So write_memory and read_memory take no transport argument and never will, there is deliberately no I3C target member of BulkOperation (it would be a value that can never be True), and — unlike its I2C and SPI counterparts — this target arms and is seedable on a host that holds no vendor USB interface at all.

Two further consequences of the private buffer, both in the I3C target’s favour: the role switch does not consume the staging pool, so an I3C target does not lock out the other two protocols’ target modes; and AsyncI3cTarget keeps write_memory and read_memory, where AsyncI2cTarget and AsyncSpiTarget deliberately have none.

Note

The I3C bus event is the one of the three that carries the transferred bytes. I3cTargetBusEventNotification is variable-length and its payload is populated — it is the one of the three that shadows the notification base’s payload with the bytes that moved on the bus. The I2C and SPI target events are fixed-length metadata records that do not shadow it, so payload there is the whole raw frame rather than the transferred data — and rather than empty, which is what this page used to claim. It is also the only one whose firmware result field is computed rather than hard-coded to success — though this SDK surfaces no result field on any of the three. And note that target_address on the I3C event is always this device’s own dynamic address, for every event kind: an I3C target has no way to learn the controller’s address. See Target mode for the side-by-side.

Two of initialize’s arguments are typed by an enum, and both are easy to get wrong.

I3cTargetMemoryLayout — memory_layout.

The adapter’s local arena is a fixed 1024 bytes whatever you choose; the layout only decides how many of those bytes one addressable register spans. MEMORY_1_BYTE_X_1024_REGS, MEMORY_2_BYTES_X_512_REGS and MEMORY_4_BYTES_X_256_REGS therefore give valid register indices of 0–1023, 0–511 and 0–255. That matters beyond bring-up, because the address taken by write_memory and read_memory — and the memory_address field of I3cTargetBusEventNotification — is a register index, not a byte offset: firmware multiplies it by the layout’s register size before indexing the arena. An index past the end is a ValidationError here rather than a firmware refusal, and an access that would overrun the arena is rejected rather than silently clamped as firmware would clamp it. The SDK remembers the layout you passed in order to do that, which is why there is no way to read it back.

I3cTargetDcr — dcr.

The Device Characteristics Register value the adapter advertises, and the one bring-up argument with no discoverable values: dcr is a mandatory int and 0xC5 is not something you guess. The three members — SECONDARY_CONTROLLER, MEMORY (0xC5) and MICROCONTROLLER — are the ones upstream expects an adapter to present.

The parameter’s declared type stays int, deliberately: upstream documents the DCR as a plain 8-bit wire value and any of 0x00–0xFF is legal, so these are convenience presets rather than a closed set. The enum is arithmetic (enum.IntEnum), which buys both halves of that: passing I3cTargetDcr.MEMORY as dcr satisfies an int parameter with no .value hop, and info.dcr == I3cTargetDcr.MEMORY holds against the bare int that getdcr and I3cTargetInfo.dcr hand back.

I3cTarget has no bring_up(), unlike the controller — and needs none, because initialize is already the re-runnable call. Upstream’s i3cTargetInit re-initialises the I3C peripheral unconditionally, so it never reports FW_INTERFACE_ALREADY_INITIALIZED. Call initialize() on every run. Do not wrap it in the three-arm fallback from the I2C guide: the configure arm can never fire, so that is dead code around a call that already does the right thing.

The absence is a decision, not a gap, and there are two reasons. A bring_up here would be initialize under another name with an unreachable configure arm, returning a BringUpResult that could only ever read already_initialized=False, settings_applied=True — no information, from a record whose entire purpose is to carry some. And it would be dead code shaped exactly like the live fallback in the four controllers, where the subtlety is which exceptions the configure arm filters; two shipped attempts got that wrong on a path that did execute, so an unreachable copy is an invitation to repeat it.

The second reason is the one that would still hold if firmware ever did start refusing a target re-init. i3cTargetInit does not bring an idle interface up — it changes the adapter’s bus role. A refusal therefore leaves the adapter in whatever role it already had, quite possibly controller, driving the bus. For a controller, “refused reconfigure, so the interface is still up and usable” is true, and is what makes a returned result honest. For a target there is no equivalent claim: “still up” would describe an adapter that is up as the wrong kind of device. So even then, an exception rather than a result is the honest answer.

configure remains the way to change parameters without a re-init.

Do not generalise that absence to target mode as a whole. I2cTarget and SpiTarget each have a bring_up, and for the exact inverse reason: their initialize is non-idempotent, reports FW_INTERFACE_ALREADY_INITIALIZED, and points at a SetParameters sibling as the idempotent way to re-run configuration — so their fallback arm is live code on the second run of any script rather than dead code. See Target mode.

A single-adapter bench cannot demonstrate a real controller-driven exchange; the target-mode examples say so in their own docstrings.

Events

I3cIbiNotification, I3cHotJoinNotification, I3cTargetBusEventNotification and I3cConnectorEventNotification (physical plug/unplug on the I3C connector) all arrive through Device.subscribe. See Notifications.

I3cConnectorEventNotification is the one that needs three enums to read, one per field: I3cConnectorPort (LV or HV — which of the two I3C ports it happened on), I3cConnectorEvent (PLUGGED or UNPLUGGED) and I3cConnectorType (what was recognised: HARNESS, QWIIC_ADAPTER, SENSEPEEK, UNIDENTIFIED, NONE or ERROR).

All three fields cross from firmware as plain int, so all three enums are bound arithmetic (enum.IntEnum) and the natural filter works as written:

from pycosmicsdk import I3cConnectorEvent, I3cConnectorEventNotification, I3cConnectorPort

plugged = dev.subscribe(
    I3cConnectorEventNotification,
    lambda n: print(f"connector type {n.connector_type} on the HV port"),
    where=lambda n: n.event == I3cConnectorEvent.PLUGGED
    and n.port == I3cConnectorPort.HV,
)

Two habits go with that. Compare, never construct — do not write I3cConnectorType(n.connector_type) inside a where= predicate: predicates run on the dispatcher thread, where an exception is logged and swallowed, so a connector type a newer firmware reports would turn a ValueError into a silently dropped event. And compare explicitly rather than testing for truthiness: IntEnum members are int, so the zero-valued LV, PLUGGED and UNIDENTIFIED are all falsy.

Errors

Firmware-reported I3C failures are I3cError. Voltage is the exception: every voltage rejection lands in the 0x01xx system band and arrives as SystemFirmwareError. Argument problems caught Python-side — field widths, the HDR-DDR length/command constraints, target memory bounds — are ValidationError.

Examples

examples/blocking_api/i3c/

discovery.py — bring-up and target discovery. ccc_queries.py — per-target GETPID / GETBCR / GETDCR. capability_rejection.py — the Pulsar has no I3C port. target_mode_setup.py — target-mode bring-up and a local memory round-trip. target_mode_bus_event.py — waiting on a bus event as a target.

examples/async_api/i3c/

discovery.py — the async twin, plus an extra step with no blocking counterpart: one GETBCR per discovered target, concurrently. target_mode_setup.py — async target mode.

Reference