# I2C {py:obj}`~pycosmicsdk.I2cController` drives one I2C bus of an open adapter. Get one from {py:obj}`Device.i2c() `; the async twin {py:obj}`~pycosmicsdk.AsyncI2cController` comes from {py:obj}`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 {py:obj}`~pycosmicsdk.CapabilityError`. What is actually available is in {py:obj}`DeviceCapabilities.i2c_buses `. ## Bring-up ```python 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. {py:obj}`~pycosmicsdk.I2cController.bring_up` is the re-runnable bring-up, and the one to reach for. It tries {py:obj}`~pycosmicsdk.I2cController.initialize`, falls back to {py:obj}`~pycosmicsdk.I2cController.configure` when the bus is already up, and returns a {py:obj}`~pycosmicsdk.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()`](index.md). 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: ```python 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 {py:obj}`~pycosmicsdk.I2cController.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. {py:obj}`~pycosmicsdk.I2cController.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: ```python 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 {py:obj}`~pycosmicsdk.I2cController.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 {py:obj}`~pycosmicsdk.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 {py:obj}`dev.capabilities.i2c_runtime_reconfigurable ` to branch on this instead of catching: ```python 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 {py:obj}`~pycosmicsdk.I2cController.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 {py:obj}`~pycosmicsdk.I2cController.configure` and {py:obj}`~pycosmicsdk.I2cController.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 {py:obj}`~pycosmicsdk.I2cError`: upstream `i2cControllerInit` reports no 0x02xx code, so they arrive as plain {py:obj}`~pycosmicsdk.FirmwareError` (`FW_INTERFACE_ALREADY_INITIALIZED`, `FW_BUS_NOT_SUPPORTED`, `FW_INVALID_PARAMETER`). Both supported adapters accept an SCL clock up to 1 MHz. {py:obj}`~pycosmicsdk.I2cController.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 {py:obj}`~pycosmicsdk.I2cController.use_external_voltage` instead and read back what the adapter measured with {py:obj}`~pycosmicsdk.I2cController.get_bus_voltage`, which {py:obj}`~pycosmicsdk.AsyncI2cController` mirrors — see [Blocking and asyncio](../asyncio.md). {py:obj}`~pycosmicsdk.I2cController.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 {py:obj}`~pycosmicsdk.I2cController.write` and {py:obj}`~pycosmicsdk.I2cController.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. {py:obj}`~pycosmicsdk.I2cController.scan` returns an {py:obj}`~pycosmicsdk.I2cScanResult` with `addresses_7bit` and, when `include_10bit=True`, `addresses_10bit`. :::{warning} **An empty bus is an exception, not an empty result.** {py:obj}`~pycosmicsdk.I2cController.scan` on a bus with nothing on it raises {py:obj}`~pycosmicsdk.I2cError` carrying `FW_I2C_BUS_WITH_NO_TARGETS_CONNECTED`; it does **not** return an {py:obj}`~pycosmicsdk.I2cScanResult` whose `addresses_7bit` is empty. So this, the obvious shape, can never run its `if` branch: ```python 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 {py:obj}`~pycosmicsdk.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](../migration.md). ::: ### How large a transfer can be {py:obj}`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 {py:obj}`~pycosmicsdk.I2cController.write` and {py:obj}`~pycosmicsdk.I2cController.read` selects, and {py:obj}`I2cController.max_transfer_bytes ` reports how far the default reaches on this connection: ```python 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 {py:obj}`~pycosmicsdk.I2cTransport`. Its three members are `AUTO` (the default), `BULK` (require the plane; raises {py:obj}`~pycosmicsdk.CapabilityError` before any request when it is unavailable) and `HID` (never negotiate). :::{important} **`I2cTransport.AUTO` means something narrower than {py:obj}`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.** {py:obj}`~pycosmicsdk.I2cController.write` and {py:obj}`~pycosmicsdk.I2cController.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 {py:obj}`~pycosmicsdk.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. {py:obj}`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 {py:obj}`~pycosmicsdk.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](bulk.md). ## 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. {py:obj}`~pycosmicsdk.I2cTarget` is that interface, with an {py:obj}`~pycosmicsdk.AsyncI2cTarget` twin. {py:obj}`Device.i2c_target() ` is the accessor, and it takes a `bus=` like {py:obj}`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](target-mode.md) 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**: {py:obj}`I2cTarget.release ` sends the device's `I2C_DEINIT` and hands back the role and the arena together, with no reboot and no fresh {py:obj}`~pycosmicsdk.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 {py:obj}`~pycosmicsdk.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 {py:obj}`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 {py:obj}`I2cTarget.write_memory ` or {py:obj}`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 {py:obj}`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 {py:obj}`~pycosmicsdk.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 {py:obj}`~pycosmicsdk.FirmwareError`. A rejected voltage is in the 0x01xx system band and arrives as {py:obj}`~pycosmicsdk.SystemFirmwareError`. So: catch {py:obj}`~pycosmicsdk.I2cError` when you specifically want bus faults, and {py:obj}`~pycosmicsdk.FirmwareError` to cover every firmware-reported I2C failure. Argument problems caught Python-side, before any USB traffic, are {py:obj}`~pycosmicsdk.ValidationError`. **Target mode adds seven codes to the same 0x02xx band**, so they too arrive as {py:obj}`~pycosmicsdk.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 {py:obj}`~pycosmicsdk.FirmwareError`, and the bulk-unavailable condition, which the target interface pre-empts as a {py:obj}`~pycosmicsdk.CapabilityError` before anything is sent. :::{important} Neither of those covers a capability refusal. {py:obj}`~pycosmicsdk.CapabilityError` derives from {py:obj}`~pycosmicsdk.CosmicError`, not from {py:obj}`~pycosmicsdk.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 {py:obj}`~pycosmicsdk.DeviceCapabilities` first and you never need it. Full account in [Capability refusals arrive before the wire](../reference/errors.md). ::: ## 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 - {py:obj}`pycosmicsdk.I2cController` / {py:obj}`pycosmicsdk.AsyncI2cController` - {py:obj}`pycosmicsdk.I2cBus`, {py:obj}`pycosmicsdk.I2cPullUp`, {py:obj}`pycosmicsdk.I2cOpenDrainRate` - {py:obj}`pycosmicsdk.I2cScanResult`, {py:obj}`pycosmicsdk.BringUpResult` - {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`, {py:obj}`pycosmicsdk.I2cTargetBusEvent`, {py:obj}`pycosmicsdk.I2cTargetBusEventKind`, {py:obj}`pycosmicsdk.I2cTargetBusEventNotification` - [The bulk transfer plane](bulk.md), [Target mode](target-mode.md)