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:
I3cControllerThe adapter as controller. Bus bring-up, dynamic address assignment, and SDR / HDR-DDR / legacy-I2C transfers. From
Device.i3c().I3cCccCommon command codes, as a sub-interface reached through
I3cController.ccc.I3cTargetThe 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:
|
Effect |
|---|---|
|
drives the low-voltage port |
|
drives the high-voltage port |
|
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:
hdr_ddr_write/hdr_ddr_read— HDR-DDR, which also takes acommandbyte 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 thei2c_open_drain_rategiven tobring_up. The I3C engine does not wait for a target that stretches SCL: callenable_i2c_clock_stretchingfirst (firmware 4.6.0+), or use the I2C port.examples/blocking_api/i3c/legacy_i2c_target.pyshows 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) andHJ(hot-join,8). It is theeventsargument ofenec_direct,enec_broadcastand theirdisectwins. It is the one enum here that is bitwise-combinable — anenum.IntFlag, soccc.enec_direct(0x08, I3cEvent.IBI | I3cEvent.HJ)arms both on the target at0x08in one command. Bound any other way, that|would be aTypeErrorand only one event could be enabled per CCC.I3cTransferDirection— which way a CCC frame moves.WRITEorREAD, thedirectionargument ofrstact_directand ofAsyncI3cCcc.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_byteargument ofrstact_broadcastandrstact_direct. Nine members: the fourNO_RESET/RESET_*actions,VIRTUAL_TARGET_DETECT, and the fourRETURN_*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_REGSandMEMORY_4_BYTES_X_256_REGStherefore give valid register indices of0–1023,0–511and0–255. That matters beyond bring-up, because theaddresstaken bywrite_memoryandread_memory— and thememory_addressfield ofI3cTargetBusEventNotification— 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 aValidationErrorhere 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:
dcris a mandatoryintand0xC5is not something you guess. The three members —SECONDARY_CONTROLLER,MEMORY(0xC5) andMICROCONTROLLER— 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 of0x00–0xFFis legal, so these are convenience presets rather than a closed set. The enum is arithmetic (enum.IntEnum), which buys both halves of that: passingI3cTargetDcr.MEMORYasdcrsatisfies anintparameter with no.valuehop, andinfo.dcr == I3cTargetDcr.MEMORYholds against the bareintthatgetdcrandI3cTargetInfo.dcrhand 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
pycosmicsdk.I3cTargetInfo,pycosmicsdk.I3cTargetConfiguration,pycosmicsdk.I3cTargetBusEvent,pycosmicsdk.I3cGetMrlResult,pycosmicsdk.BringUpResultBus parameters:
pycosmicsdk.I3cPushPullRate,pycosmicsdk.I3cOpenDrainRate,pycosmicsdk.I2cOpenDrainRate,pycosmicsdk.I3cDriveStrengthTransfers and CCCs:
pycosmicsdk.I3cTransferFlags,pycosmicsdk.I3cTransferDirection,pycosmicsdk.I3cEvent,pycosmicsdk.I3cTargetResetDefByte,pycosmicsdk.I3cTransportThe bulk transfer plane — the controller-role bulk route, its ceiling, and the HDR-DDR cap
Target mode:
pycosmicsdk.I3cTargetMemoryLayout,pycosmicsdk.I3cTargetDcr,pycosmicsdk.I3cTargetBusEventKindConnector events:
pycosmicsdk.I3cConnectorPort,pycosmicsdk.I3cConnectorEvent,pycosmicsdk.I3cConnectorType