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.
writeandreadare 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 at0x50, 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 ofblocking.py, same output.concurrent_ops.py— async-only: several reads and a scan fired at once withasyncio.gatheron a single device.
Reference
pycosmicsdk.I2cBus,pycosmicsdk.I2cPullUp,pycosmicsdk.I2cOpenDrainRatepycosmicsdk.I2cTarget/pycosmicsdk.AsyncI2cTarget,pycosmicsdk.I2cTargetModel,pycosmicsdk.I2cTargetAddressMode,pycosmicsdk.I2cTargetMemoryLayout,pycosmicsdk.I2cTargetRegisterAccess,pycosmicsdk.I2cTargetEraseScope,pycosmicsdk.I2cTargetEepromBusyBehavior,pycosmicsdk.I2cTargetBusEvent,pycosmicsdk.I2cTargetBusEventKind,pycosmicsdk.I2cTargetBusEventNotification