# I3C I3C is **Supernova-only** — the Pulsar has no I3C hardware, so {py:obj}`Device.i3c() ` on a Pulsar raises {py:obj}`~pycosmicsdk.CapabilityError` before anything reaches the bus. `examples/blocking_api/i3c/capability_rejection.py` demonstrates exactly that. Three interfaces cover the two bus roles: {py:obj}`~pycosmicsdk.I3cController` : The adapter as controller. Bus bring-up, dynamic address assignment, and SDR / HDR-DDR / legacy-I2C transfers. From {py:obj}`Device.i3c() `. {py:obj}`~pycosmicsdk.I3cCcc` : Common command codes, as a sub-interface reached through {py:obj}`I3cController.ccc `. {py:obj}`~pycosmicsdk.I3cTarget` : The adapter as target, answering an external controller out of its own register memory. From {py:obj}`Device.i3c_target() `. Each has an `Async` twin. Target mode runs on the same hardware, so `ProtocolInterface.I3C` in {py:obj}`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 | {py:obj}`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 {py:obj}`~pycosmicsdk.I3cController.use_external_voltage`. {py:obj}`~pycosmicsdk.AsyncI3cController.get_bus_voltage` mirrors it; see [Blocking and asyncio](../asyncio.md). The I3C rails are separate from the multifunction VTARG rail that I2C, SPI, UART and GPIO share. **{py:obj}`~pycosmicsdk.I3cController.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 {py:obj}`~pycosmicsdk.I3cController.use_external_voltage` *does* take `persist`, because that one upstream does offer. Set the I3C rail on every run. ## Bring-up and discovery ```python 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()) ``` {py:obj}`~pycosmicsdk.I3cController.bring_up` is the re-runnable bring-up and the one to reach for: it tries {py:obj}`~pycosmicsdk.I3cController.initialize`, falls back to {py:obj}`~pycosmicsdk.I3cController.configure` when the bus is already up, and returns a {py:obj}`~pycosmicsdk.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()`](index.md). 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. ```python 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 — {py:obj}`~pycosmicsdk.I3cPushPullRate` alone has 22 members, each naming a frequency *and* a duty cycle. The full lists are in [Enums](../reference/enums.md). There is a fourth bus parameter the snippets above leave at its default. `drive_strength` is an {py:obj}`~pycosmicsdk.I3cDriveStrength` — `STANDARD` or `FAST`, defaulting to `FAST` — and it is accepted by {py:obj}`~pycosmicsdk.I3cController.initialize`, {py:obj}`~pycosmicsdk.I3cController.configure` and {py:obj}`~pycosmicsdk.I3cController.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, {py:obj}`~pycosmicsdk.I3cController.initialize` is **not idempotent**: an already-initialised bus reports `FW_INTERFACE_ALREADY_INITIALIZED`, and the bus stays initialised across reconnects. {py:obj}`~pycosmicsdk.I3cController.configure` re-configures a live bus. `examples/helpers.py`'s `i3c_bring_up()` delegates to {py:obj}`~pycosmicsdk.I3cController.bring_up` and adds the examples' console lines; it no longer carries a fallback of its own. {py:obj}`~pycosmicsdk.I3cController.init_bus` issues RSTDAA then ENTDAA and returns the resulting device table as {py:obj}`~pycosmicsdk.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 {py:obj}`~pycosmicsdk.I3cCcc.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 {py:obj}`~pycosmicsdk.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 {py:obj}`~pycosmicsdk.I3cTarget.initialize` takes them in. Swapping them positionally produces two plausible numbers and no error at all. ::: {py:obj}`~pycosmicsdk.I3cController.get_device_table` re-reads the cached table without putting traffic on the bus, and {py:obj}`~pycosmicsdk.I3cController.reset_bus` clears it. ## Transfers Three families, all taking `bytes` and an `I3cTransferFlags` value: - {py:obj}`~pycosmicsdk.I3cController.sdr_write` / {py:obj}`~pycosmicsdk.I3cController.sdr_read` — normal I3C SDR. - {py:obj}`~pycosmicsdk.I3cController.hdr_ddr_write` / {py:obj}`~pycosmicsdk.I3cController.hdr_ddr_read` — HDR-DDR, which also takes a `command` byte and has its own length constraints. - {py:obj}`~pycosmicsdk.I3cController.legacy_i2c_write` / {py:obj}`~pycosmicsdk.I3cController.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. {py:obj}`~pycosmicsdk.I3cController.target_reset_pattern` and {py:obj}`~pycosmicsdk.I3cController.hdr_exit_pattern` emit the corresponding bus patterns. ### Which transport a transfer takes All six take a `transport=` argument typed {py:obj}`~pycosmicsdk.I3cTransport`, and {py:obj}`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 {py:obj}`~pycosmicsdk.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 {py:obj}`~pycosmicsdk.BulkTransportCapability.max_payload_for` rather than hard-coding either number. ::: The six {py:obj}`~pycosmicsdk.AsyncI3cController` transfer methods **exist but take no `transport` argument**, and {py:obj}`~pycosmicsdk.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](bulk.md). The async methods stay on the ordinary route at 1024 bytes. Nothing is ever split: a payload over the active ceiling raises {py:obj}`~pycosmicsdk.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](bulk.md). ### SMBus interface reset and the SDA pull-up {py:obj}`~pycosmicsdk.I3cController.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. {py:obj}`~pycosmicsdk.I3cController.set_sda_pull_up` sets the board's 1 kOhm SDA pull-up to {py:obj}`~pycosmicsdk.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. ```python i3c.set_sda_pull_up(I3cSdaPullUp.ON) held_us = i3c.hold_scl_low(35_000) ``` Firmware without either command raises {py:obj}`~pycosmicsdk.FirmwareError` (`FW_UNSUPPORTED_COMMAND`). ## Common command codes {py:obj}`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 {py:obj}`~pycosmicsdk.I3cGetMrlResult`, `getpid` gives `bytes`). {py:obj}`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](../asyncio.md). ### 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. {py:obj}`~pycosmicsdk.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 {py:obj}`~pycosmicsdk.I3cCcc.enec_direct`, {py:obj}`~pycosmicsdk.I3cCcc.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. {py:obj}`~pycosmicsdk.I3cTransferDirection` — **which way a CCC frame moves.** : `WRITE` or `READ`, the `direction` argument of {py:obj}`~pycosmicsdk.I3cCcc.rstact_direct` and of {py:obj}`AsyncI3cCcc.transfer `. Like the bus parameters it travels device-ward only: nothing decodes a direction back out of a response or a notification. {py:obj}`~pycosmicsdk.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 {py:obj}`~pycosmicsdk.I3cCcc.rstact_broadcast` and {py:obj}`~pycosmicsdk.I3cCcc.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.Enum`s, so `some_int == I3cTransferDirection.READ` is always `False` — not an oversight to work around, but the point. {py:obj}`~pycosmicsdk.I3cTransferDirection`'s `WRITE`/`READ` are `0`/`1`, the very same values as {py:obj}`~pycosmicsdk.I3cTargetBusEventKind`'s `WRITE_TRANSFER`/`READ_TRANSFER` — and *that* enum **is** arithmetic, because {py:obj}`~pycosmicsdk.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: ```python 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, ) ``` {py:obj}`~pycosmicsdk.I3cTargetResetDefByte` is the same story with a sharper edge. {py:obj}`~pycosmicsdk.I3cCcc.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 {py:obj}`~pycosmicsdk.I3cTarget` switches the adapter into the target role. {py:obj}`~pycosmicsdk.I3cTarget.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`). {py:obj}`~pycosmicsdk.I3cTarget.write_memory` and {py:obj}`~pycosmicsdk.I3cTarget.read_memory` reach that local arena from the host side, and {py:obj}`~pycosmicsdk.I3cTarget.wait_for_event` blocks for the next {py:obj}`~pycosmicsdk.I3cTargetBusEvent` — what a controller on the bus did. `I3cTargetBusEvent.kind` is an {py:obj}`~pycosmicsdk.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](target-mode.md) 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 {py:obj}`~pycosmicsdk.I3cTarget.write_memory` and {py:obj}`~pycosmicsdk.I3cTarget.read_memory` take **no `transport` argument and never will**, there is deliberately no I3C target member of {py:obj}`~pycosmicsdk.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 {py:obj}`~pycosmicsdk.AsyncI3cTarget` keeps `write_memory` and `read_memory`, where {py:obj}`~pycosmicsdk.AsyncI2cTarget` and {py:obj}`~pycosmicsdk.AsyncSpiTarget` deliberately have none. ::: :::{note} **The I3C bus event is the one of the three that carries the transferred bytes.** {py:obj}`~pycosmicsdk.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](target-mode.md) for the side-by-side. ::: Two of `initialize`'s arguments are typed by an enum, and both are easy to get wrong. {py:obj}`~pycosmicsdk.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 {py:obj}`~pycosmicsdk.I3cTarget.write_memory` and {py:obj}`~pycosmicsdk.I3cTarget.read_memory` — and the `memory_address` field of {py:obj}`~pycosmicsdk.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 {py:obj}`~pycosmicsdk.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. {py:obj}`~pycosmicsdk.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 {py:obj}`I3cTargetDcr.MEMORY ` as `dcr` satisfies an `int` parameter with no `.value` hop, and `info.dcr == I3cTargetDcr.MEMORY` holds against the bare `int` that {py:obj}`~pycosmicsdk.I3cCcc.getdcr` and {py:obj}`I3cTargetInfo.dcr ` hand back. {py:obj}`~pycosmicsdk.I3cTarget` has **no** `bring_up()`, unlike the controller — and needs none, because {py:obj}`~pycosmicsdk.I3cTarget.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](i2c.md): 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 {py:obj}`~pycosmicsdk.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. {py:obj}`~pycosmicsdk.I3cTarget.configure` remains the way to change parameters without a re-init. **Do not generalise that absence to target mode as a whole.** {py:obj}`~pycosmicsdk.I2cTarget` and {py:obj}`~pycosmicsdk.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](target-mode.md). A single-adapter bench cannot demonstrate a real controller-driven exchange; the target-mode examples say so in their own docstrings. ## Events {py:obj}`~pycosmicsdk.I3cIbiNotification`, {py:obj}`~pycosmicsdk.I3cHotJoinNotification`, {py:obj}`~pycosmicsdk.I3cTargetBusEventNotification` and {py:obj}`~pycosmicsdk.I3cConnectorEventNotification` (physical plug/unplug on the I3C connector) all arrive through {py:obj}`Device.subscribe `. See [Notifications](../reference/notifications.md). {py:obj}`~pycosmicsdk.I3cConnectorEventNotification` is the one that needs three enums to read, one per field: {py:obj}`~pycosmicsdk.I3cConnectorPort` (`LV` or `HV` — which of the two I3C ports it happened on), {py:obj}`~pycosmicsdk.I3cConnectorEvent` (`PLUGGED` or `UNPLUGGED`) and {py:obj}`~pycosmicsdk.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: ```python 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 {py:obj}`~pycosmicsdk.I3cError`. Voltage is the exception: every voltage rejection lands in the 0x01xx system band and arrives as {py:obj}`~pycosmicsdk.SystemFirmwareError`. Argument problems caught Python-side — field widths, the HDR-DDR length/command constraints, target memory bounds — are {py:obj}`~pycosmicsdk.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 - {py:obj}`pycosmicsdk.I3cController` / {py:obj}`pycosmicsdk.AsyncI3cController` - {py:obj}`pycosmicsdk.I3cCcc` / {py:obj}`pycosmicsdk.AsyncI3cCcc` - {py:obj}`pycosmicsdk.I3cTarget` / {py:obj}`pycosmicsdk.AsyncI3cTarget` - {py:obj}`pycosmicsdk.I3cTargetInfo`, {py:obj}`pycosmicsdk.I3cTargetConfiguration`, {py:obj}`pycosmicsdk.I3cTargetBusEvent`, {py:obj}`pycosmicsdk.I3cGetMrlResult`, {py:obj}`pycosmicsdk.BringUpResult` - Bus parameters: {py:obj}`pycosmicsdk.I3cPushPullRate`, {py:obj}`pycosmicsdk.I3cOpenDrainRate`, {py:obj}`pycosmicsdk.I2cOpenDrainRate`, {py:obj}`pycosmicsdk.I3cDriveStrength` - Transfers and CCCs: {py:obj}`pycosmicsdk.I3cTransferFlags`, {py:obj}`pycosmicsdk.I3cTransferDirection`, {py:obj}`pycosmicsdk.I3cEvent`, {py:obj}`pycosmicsdk.I3cTargetResetDefByte`, {py:obj}`pycosmicsdk.I3cTransport` - [The bulk transfer plane](bulk.md) — the controller-role bulk route, its ceiling, and the HDR-DDR cap - Target mode: {py:obj}`pycosmicsdk.I3cTargetMemoryLayout`, {py:obj}`pycosmicsdk.I3cTargetDcr`, {py:obj}`pycosmicsdk.I3cTargetBusEventKind` - Connector events: {py:obj}`pycosmicsdk.I3cConnectorPort`, {py:obj}`pycosmicsdk.I3cConnectorEvent`, {py:obj}`pycosmicsdk.I3cConnectorType`