I2C

I2cController drives one I2C bus of an open adapter. Get one from Device.i2c(); the async twin AsyncI2cController comes from AsyncDevice.i2c().

Buses. The Supernova has one logical bus, I2cBus.A — its Qwiic port and multifunction-port I2C pins are internally shorted. The Pulsar has two electrically separate buses, I2cBus.A and I2cBus.B, sharing only VTARG and ground. dev.i2c(bus=...) defaults to I2cBus.A; asking for a bus the adapter does not have raises CapabilityError. What is actually available is in DeviceCapabilities.i2c_buses.

Bring-up

from pycosmicsdk import Device, I2cBus, I2cPullUp

with Device.open() as dev:
    i2c = dev.i2c(bus=I2cBus.A)
    i2c.set_voltage(voltage_mv=3300)
    i2c.bring_up(frequency_hz=400_000, pull_up=I2cPullUp.OHM_330)

Order matters in practice: set the target rail before bringing the bus up, so the pull-ups are referenced to the right voltage.

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 saying what took effect. On return the bus is up and usable; anything that would leave it down raises. The contract is the same on all four protocols that have it, and is spelled out under Bring-up is bring_up().

The one field worth branching on is settings_applied. It is False only when the bus was already up and the adapter refused to reconfigure it — the bus works, but at settings you did not choose and cannot read back:

result = i2c.bring_up(frequency_hz=400_000, pull_up=I2cPullUp.OHM_330)
if result.already_initialized and not result.settings_applied:
    print(f"bus kept its earlier settings: {result.refusal}")

If your measurement depends on the frequency, raise result.refusal instead of printing.

Why bring_up exists

initialize is not idempotent: an already-initialised bus reports FW_INTERFACE_ALREADY_INITIALIZED, and the bus stays initialised across reconnects, so re-running a script hits it unless the adapter was reset. configure — same arguments — changes the parameters of a live bus.

You do not need to write that fallback any more; this section is what bring_up does for you, and is kept because the third arm is the one two shipped attempts in this repository got wrong. The fallback has three arms, not two:

from pycosmicsdk import CapabilityError, FirmwareError, I2cPullUp, StatusCode

FREQUENCY_HZ = 400_000
PULL_UP = I2cPullUp.OHM_330

settings_applied = True
already_up = False
try:
    i2c.initialize(frequency_hz=FREQUENCY_HZ, pull_up=PULL_UP)
except FirmwareError as exc:
    if exc.status_code is not StatusCode.FW_INTERFACE_ALREADY_INITIALIZED:
        raise
    already_up = True

if already_up:
    try:
        i2c.configure(frequency_hz=FREQUENCY_HZ, pull_up=PULL_UP)
    except CapabilityError:
        # A capability guard refused before anything reached the device, so the
        # bus is still running exactly as the earlier bring-up left it -- which
        # is this fallback's case by definition. NOT status-filtered: the guards
        # raise SDK_ERROR_WRONG_REQUEST, not a firmware rejection code.
        settings_applied = False
    except FirmwareError as exc:
        # Here the status code *is* what separates a refused reconfigure from a
        # genuine fault, so this arm keeps its filter.
        if exc.status_code is not StatusCode.FW_FEATURE_NOT_SUPPORTED_BY_HARDWARE:
            raise
        settings_applied = False

examples/helpers.py’s i2c_bring_up() — which every I2C example calls — delegates to bring_up and adds the examples’ console lines. The fallback itself lives in the SDK, and is unit-tested there.

Why configure can be refused before the wire. Reconfiguring a live I2C interface depends on the adapter’s hardware revision, not its model: on a Supernova whose hw_version is anything but C it is impossible, and a guard raises CapabilityError rather than letting the call through — deliberately, because the firmware applies the new baud rate before it checks the revision, so the round trip would re-initialise the peripheral at the new rate and only then report total failure. Ask dev.capabilities.i2c_runtime_reconfigurable to branch on this instead of catching:

if dev.capabilities.i2c_runtime_reconfigurable:
    i2c.configure(frequency_hz=1_000_000, pull_up=I2cPullUp.OHM_330)
else:
    print("this unit keeps the settings its first bring-up chose")

The pull-up value is gated too, on the model this time: firmware picks its resistor table at build time, so I2cPullUp.KOHM_3_3, KOHM_4_7 and KOHM_10_0 are Supernova-only and KOHM_4_0 is Pulsar-only. All three of initialize, configure and set_pull_up refuse an unsupported value with CapabilityError before any USB traffic. Ask dev.capabilities.supports_i2c_pull_up(pull_up), or read dev.capabilities.i2c_pull_ups for the whole accepted set. I2cPullUp.OHM_330 is the only value confirmed accepted on both adapters.

Warning

On a Supernova Rev B the pull_up you pass to initialize is validated, accepted, and then ignored. That revision has no pull-up potentiometer to program — firmware switches a single fixed network instead and never looks at the requested value again — so all twelve values the Supernova table accepts give the same resistance on that board. Nothing is reported: the call returns successfully. It is the same missing hardware that makes configure and set_pull_up refusable on the same unit — i2c_runtime_reconfigurable reading False is the reliable signal that you are on such a board.

This one is read off the firmware source and has not been confirmed on a bench, and the resistance the fixed network actually presents has never been measured, so do not infer a value from it. It is stated here because a silent difference between what you asked for and what the bus is doing is exactly the thing a timing investigation must not discover late. On a Rev C Supernova and on a Pulsar the value is programmed as requested.

Bring-up failures never surface as I2cError: upstream i2cControllerInit reports no 0x02xx code, so they arrive as plain FirmwareError (FW_INTERFACE_ALREADY_INITIALIZED, FW_BUS_NOT_SUPPORTED, FW_INVALID_PARAMETER). Both supported adapters accept an SCL clock up to 1 MHz.

set_voltage moves the shared multifunction rail — SPI, UART and GPIO ride the same supply. Pass persist=True to keep it across power cycles. If the target board supplies its own rail, call use_external_voltage instead and read back what the adapter measured with get_bus_voltage, which AsyncI2cController mirrors — see Blocking and asyncio.

set_pull_up changes the internal pull-up selection without a full re-configure. It is behind both guards above — the revision limit and the per-model value table — so it too can raise CapabilityError before any USB traffic.

Operations

write and read take a 7-bit address (0x00–0x7F), an optional subaddress (register pointer, as bytes — so a 16-bit register is b"\x01\x00"), and non_stop:

  • non_stop=False (the default) — sub-address write, STOP, then a fresh START for the data phase.

  • non_stop=True — repeated START, no STOP between the phases. This is what most EEPROMs and FRAMs want for a random read.

scan returns an I2cScanResult with addresses_7bit and, when include_10bit=True, addresses_10bit.

Warning

An empty bus is an exception, not an empty result. scan on a bus with nothing on it raises I2cError carrying FW_I2C_BUS_WITH_NO_TARGETS_CONNECTED; it does not return an I2cScanResult whose addresses_7bit is empty. So this, the obvious shape, can never run its if branch:

try:
    found = i2c.scan()
except I2cError as exc:
    if exc.status_code is not StatusCode.FW_I2C_BUS_WITH_NO_TARGETS_CONNECTED:
        raise
    found = None          # nothing on the bus -- this is the "empty" case
if found is None or not found.addresses_7bit:
    print("no targets")

Confirmed on both adapters and on both of the Pulsar’s buses. One consequence worth naming: because the whole call raises, include_10bit=True on an empty bus produces no 10-bit lane to inspect either.

Note

10-bit addressing is discovery-only on the native API. scan finds 10-bit targets, but write and read cannot address one: upstream declares the target as a single uint8_t on both the blocking and the async transfer signature, so there is nowhere on the wire to put the extra three bits. An address above 0x7F raises ValidationError before any USB traffic. This is a real gap rather than an oversight in the Python layer, and the only route to a 10-bit transfer today is the legacy supernovasdk / pulsarsdk shims, which pack the address host-side — see the migration guide.

How large a transfer can be

DeviceCapabilities.max_i2c_payload_bytes reports the ordinary HID route’s cap (1024). Above that, a transfer can travel over the adapter’s vendor bulk USB endpoint instead — the same frames on the same pins at the same rate, with a larger per-call ceiling. transport= on write and read selects, and I2cController.max_transfer_bytes reports how far the default reaches on this connection:

i2c.write(address=0x50, data=b"\x00" * 4096, subaddress=b"\x00\x00")
print(f"up to {i2c.max_transfer_bytes} bytes per call on this connection")

The enum is I2cTransport. Its three members are AUTO (the default), BULK (require the plane; raises CapabilityError before any request when it is unavailable) and HID (never negotiate).

Important

I2cTransport.AUTO means something narrower than SpiTransport.AUTO does, and the difference is deliberate. SPI’s AUTO prefers the bulk plane whenever it is available. I2C’s takes the ordinary HID route for every request that fits its 1024-byte ceiling and reaches for bulk only above it. Two reasons, both specific to I2C:

  • There is nothing to win below the ceiling. I2C is bus-limited long before the transport is, so what bulk removes is the per-1024-byte command round trip, not the bus time. A 4-byte register write is bus-bound either way.

  • There is something to lose. write and read are shipped methods with existing callers, and the bulk route acquires the device’s shared staging pool — the same pool an armed I2C or SPI target draws its arena from. Routing every small write through it would expose those callers to a contention failure they cannot have today, for no throughput gain.

So AUTO is the transport that never changes the wire for a call that already worked, and lifts the ceiling for one that could not. There is consequently no transport property on I2cController, where SPI and I3C both have one: I2C’s AUTO consults the payload length, so the answer is not a pure function of the connection and the property would have to describe a request that has not been made yet. I2cController.max_transfer_bytes answers the useful question instead.

Nothing is ever split across two calls: a payload over the active transport’s ceiling raises ValidationError naming that transport and its ceiling. The selection is logged once per controller at INFO on the pycosmicsdk.i2c logger, either way round. Ceilings, how to diagnose an unavailable plane, and why the async twins take no transport argument are all in The bulk transfer plane.

Target mode

The adapter can also answer an I2C bus rather than drive it, serving reads and writes out of a memory arena as a flat memory, a 24Cxx-style EEPROM or a register map. I2cTarget is that interface, with an AsyncI2cTarget twin.

Device.i2c_target() is the accessor, and it takes a bus= like Device.i2c() does — cached under a distinct key, so one bus’s target and controller interfaces are two objects rather than one.

Read Target mode before calling anything on it. In brief: the I2C role is exclusive per device rather than per bus for the target direction, so “controller on A, then target on B” is refused on a Pulsar; a target and an ordinary controller cannot be up at the same time in either order; and only one target protocol can be armed at a time, because the arena is the shared bulk staging pool.

All three are constraints on what can be up at once, not on the power cycle, because the role is releasable in one call: I2cTarget.release sends the device’s I2C_DEINIT and hands back the role and the arena together, with no reboot and no fresh Device. It releases whichever role the bus holds, a controller included, so dev.i2c_target(bus=...).release() is also how a program hands a shared bus to another adapter — and it is why I2cController deliberately has no release() of its own. Two details to plan around: the guard is per device while the release is per bus, so freeing a two-bus adapter for a target bring-up takes a release on each; and a bus holding no role answers FW_INTERFACE_NOT_INITIALIZED rather than succeeding quietly, which is how a caller makes a release idempotent. This page said until 2026-09-04 that the bring-up was not reversible and that Device.reboot was the only release from Python. That was true of an older CosmicSDK pin; it is false now, and a reboot is the fallback only on firmware predating the deinit dispatch, which answers FW_UNSUPPORTED_COMMAND and is indistinguishable by version string.

Two I2C-specific things worth knowing before you read that page. The arena is bulk-only — there is no HID route to I2cTarget.write_memory or I2cTarget.read_memory — so bring-up refuses up front on a host that cannot claim the vendor USB interface, rather than arming a target nobody can seed. And I2cTarget.wait_for_event pops exactly one event per completed bus transaction — firmware emits one per transfer phase and flushes them at the STOP, and an ordinary write or read is one phase — but a non_stop=True transfer emits nothing at all while it holds the bus, so waiting for its event blocks until unrelated code issues the STOP. This page previously said the opposite (several events per transaction); see the target-mode guide’s caution for the measured model.

Errors

Not every I2C failure is an I2cError. The 0x02xx firmware band maps to it and is well populated, so genuine bus-level failures do land there — address NACK, arbitration lost, SCL held low, a pull-up setting failure. Bring-up failures do not: a rejected frequency or pull-up is FW_INVALID_PARAMETER, an unsupported bus id is FW_BUS_NOT_SUPPORTED, an uninitialised bus is FW_INTERFACE_NOT_INITIALIZED — all generic codes typed FirmwareError. A rejected voltage is in the 0x01xx system band and arrives as SystemFirmwareError.

So: catch I2cError when you specifically want bus faults, and FirmwareError to cover every firmware-reported I2C failure. Argument problems caught Python-side, before any USB traffic, are ValidationError.

Target mode adds seven codes to the same 0x02xx band, so they too arrive as I2cError: FW_I2C_ROLE_CONFLICT, FW_I2C_TARGET_STAGING_UNAVAILABLE, FW_I2C_TARGET_SURPASSED_MEMORY_SIZE, FW_I2C_TARGET_PAYLOAD_TRANSFER_FAILED, FW_I2C_TARGET_WRONG_MODEL, FW_I2C_TARGET_INVALID_CONFIG and FW_I2C_TARGET_REGION_TABLE_FULL. The two exceptions are FW_INTERFACE_ALREADY_INITIALIZED, which is generic and typed FirmwareError, and the bulk-unavailable condition, which the target interface pre-empts as a CapabilityError before anything is sent.

Important

Neither of those covers a capability refusal. CapabilityError derives from CosmicError, not from FirmwareError, so except FirmwareError around initialize, configure or set_pull_up will not catch it — and because nothing was sent, it carries StatusCode.SDK_ERROR_WRONG_REQUEST rather than a firmware rejection code, so a handler that does catch it must not filter on status_code. The bring-up snippet above has the shape that works; ask DeviceCapabilities first and you never need it. Full account in Capability refusals arrive before the wire.

Examples

examples/blocking_api/i2c/

blocking.py — the full round trip: VTARG, bring-up, pull-up refresh, non-stop and standard write/read against a memory target at 0x50, then a bus scan. blocking_manual_close.py — the same without the context manager. error_handling.py — provokes a failure per layer and shows which exception each one raises. target_arena_bulk.py — 16 KB through the staging arena, byte-exact. Read its first paragraph: 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. target_eeprom_driven.py — two adapters, one emulating a 24Cxx EEPROM for the other to read and write, ending in a bus event whose payload has to be fetched from the arena separately.

examples/async_api/i2c/

i2c_roundtrip.py — the async twin of blocking.py, same output. concurrent_ops.py — async-only: several reads and a scan fired at once with asyncio.gather on a single device.

Reference