Inter-Integrated Circuit (I2C)#

Using I2CInterface for vendor-independent I2C communication

I2CInterface#

I2CInterface is a hardware abstraction layer (HAL) that provides a unified interface for I2C communication across multiple adapter vendors. The key benefit is vendor-independent code: create your SystemDefinition once, and the same code works with different I2C adapters.

Supported Vendors#

  • Total Phase - Aardvark I2C/SPI Host Adapter

If your adapter vendor is not listed, custom driver development is available to add support for your hardware.

Key Concepts#

System Definition#

I2CInterface’s architecture utilizes a SystemDefinition to provide a low-code, human-readable way to interact with I2C devices on the bus. This design reduces the need for magic numbers, manual bitwise operations, and scattered hardware knowledge throughout your test code.

The SystemDefinition serves as a single source of truth about your I2C bus configuration, centralizing:

  • Device addresses and names

  • Register maps and bit field definitions

  • Data formats and scaling functions

  • Command definitions for command-based devices

Instead of writing code like:

# Hard-coded addresses, magic numbers, manual bit manipulation
i2c.write(0x20, 0x01, 0xFF)
value = i2c.read(0x20, 0x01)
mode = (value & 0x06) >> 1  # Manual bit masking

You write code like:

# Clear, readable, maintainable
i2c.write("gpio_expander", "OUTPUT", 0xFF)
value = i2c.read("gpio_expander", "OUTPUT")
mode = i2c.read("gpio_expander", "CONFIG", field="mode")

See the System Definition page for detailed information on creating and configuring your SystemDefinition.

Tip

Create your SystemDefinition in a separate file and import it into your test code. This cleanly separates hardware configuration (typically done by a hardware/firmware engineer) from the test and automation logic (usually the test engineer’s domain), leading to more maintainable and collaborative code.

Lifecycle Pattern#

The typical I2CInterface workflow follows this pattern:

  1. Create a SystemDefinition - Define all I2C devices, registers, and commands (see System Definition)

  2. Construct I2CInterface(name, driver=..., system_definition=...) - Compose a vendor driver (e.g. Aardvark) and pass it directly

  3. open() - Establish connection to the I2C adapter hardware

  4. start() - Begins a periodic daemon in the background. (Optional)

  5. Configure and communicate - Access registers, fields, or send commands to devices

  6. stop() - End background daemon (if started)

  7. close() - Disconnect from hardware

Custom Background Daemon

  • To define your own background daemon, call define_background_daemon().

  • To add a method to the background daemon stack, call add_background_daemon_function().

See Two ways to get data for more information regarding background fetching of measurements.

Note

Important Note about Publishers

Data is published as a direct result of an instrument method being called.

For example, when you call read(), this not only returns a register value but also causes all attached Publishers to publish the response automatically.

Therefore the background daemon, when calling these instrument methods, is publishing data in the background as well!

Device Types#

I2CInterface’s System Definition supports two types of I2C devices:

  • Register-based devices - Devices with register maps (e.g., GPIO expanders, sensors with registers)

  • Command-based devices - Devices that respond to command bytes (e.g., ADCs that accept channel selection commands)

Both device types are configured in the SystemDefinition with human-readable names, allowing you to access devices without remembering raw I2C addresses.

Note

You can read and write directly to the I2C bus using write_raw() and read_raw(), which bypasses the benefits provided by the SystemDefinition architecture. This is useful for using I2C devices that I2CInterface doesn’t yet provide lower-code interactions with, allowing you to move forward regardless.

Creating an I2CInterface Instance#

Construct I2CInterface directly. The caller picks a vendor driver and passes it in. There is no factory method or vendor enum.

from instro.i2c.drivers.totalphase import Aardvark
from instro.i2c import I2CInterface
from instro.i2c.types import SystemDefinition

# Create system definition (see System Definition page for details)
system = SystemDefinition()
# ... add devices to system definition ...

# Create I2C instrument
i2c = I2CInterface(
    name="main_i2c",
    driver=Aardvark(serial_number="123456"),
    system_definition=system,
)

Parameters#

  • name: A name for this I2C instance. Used as a prefix for channel names when using a publisher.

  • driver: An I2CDriverBase implementation (e.g. Aardvark(serial_number=...))

  • system_definition: Complete SystemDefinition object describing all I2C devices (required)

  • publishers: Optional list of publishers to attach

  • **kwargs: Additional keyword arguments become default tags when using a publisher that supports tags (like NominalCorePublisher).

Warning

SystemDefinition Required

I2CInterface requires a SystemDefinition parameter. You cannot create a I2CInterface instance without first defining your I2C devices. See the System Definition page for details.

Examples#

All measurement methods return Measurement objects. This is common amongst all Instrument objects.

All examples below import from a shared system_definition.py file. This follows the recommended practice of separating hardware configuration from test logic.

System Definition File#

First, create a system_definition.py file that defines your I2C bus configuration:

# system_definition.py
from instro.i2c.types import (
    SystemDefinition,
    RegisterDevice,
    RegisterDef,
    FieldDef,
    CommandDevice,
    DataFormat,
    LinearScaling,
)
from enum import Enum

# Command enum for ADC
class ADCChannel(Enum):
    CH0 = 0x00
    CH1 = 0x10

def create_system_definition() -> SystemDefinition:
    """Create and return the complete I2C system definition."""
    system = SystemDefinition()

    # GPIO Expander (register-based device)
    gpio = RegisterDevice(
        name="gpio_expander",
        address=0x20,
        addr_width_bytes=1,
        registers={
            "OUTPUT": RegisterDef(
                alias="OUTPUT",
                register=0x01,
                default_value=0x00,
                format=DataFormat(transfer_bits=8),
            ),
            "CONFIG": RegisterDef(
                alias="CONFIG",
                register=0x0A,
                default_value=0x00,
                format=DataFormat(transfer_bits=8),
                fields={
                    "interrupt_enable": FieldDef(name="interrupt_enable", lsb=0, width_bits=1),
                    "interrupt_polarity": FieldDef(name="interrupt_polarity", lsb=1, width_bits=1),
                }
            ),
        }
    )
    system.add_device(gpio)

    # ADC (command-based device)
    adc = CommandDevice(
        name="adc",
        address=0x48,
        data_format=DataFormat(
            transfer_bits=16,
            signed=True,
            scaling=LinearScaling(gain=0.001, offset=0.0),  # 1 mV per LSB
            units="V"
        ),
        endianness="big",
        batch_commands={
            "read_ch0": [ADCChannel.CH0],
            "read_ch1": [ADCChannel.CH1],
        }
    )
    system.add_device(adc)

    # Temperature sensor (command-based device)
    class TempCommand(Enum):
        START_CONVERSION = 0x01
        READ_RESULT = 0x02

    temp_sensor = CommandDevice(
        name="temperature_sensor",
        address=0x4A,
        data_format=DataFormat(
            transfer_bits=16,
            data_width_bits=12,
            signed=True,
            scaling=LinearScaling(gain=0.0625, offset=0.0),  # 0.0625°C per LSB
            units="°C"
        ),
        endianness="big",
        batch_commands={
            "read_temperature": [TempCommand.START_CONVERSION, TempCommand.READ_RESULT],
        }
    )
    system.add_device(temp_sensor)

    return system

Basic Register Read/Write#

from instro.i2c.drivers.totalphase import Aardvark
from instro.i2c import I2CInterface
from system_definition import create_system_definition

# Import the shared system definition
system = create_system_definition()

# Create I2C instrument
i2c = I2CInterface(
    name="main_i2c",
    driver=Aardvark(serial_number="123456"),
    system_definition=system,
)

i2c.open()

# Write to register
i2c.write("gpio_expander", "OUTPUT", 0xFF)

# Read from register
measurement = i2c.read("gpio_expander", "OUTPUT")
print(f"Output state: {measurement.latest}")

i2c.close()

Field-Level Register Access#

For registers with bit fields, you can read and write individual fields:

from instro.i2c.drivers.totalphase import Aardvark
from instro.i2c import I2CInterface
from system_definition import create_system_definition

# Import the shared system definition
system = create_system_definition()

i2c = I2CInterface(
    name="main_i2c",
    driver=Aardvark(serial_number="123456"),
    system_definition=system,
)

i2c.open()

# Read specific field (automatically masks and shifts)
enable = i2c.read("gpio_expander", "CONFIG", field="interrupt_enable")

# Write to specific field (read-modify-write operation)
i2c.write("gpio_expander", "CONFIG", 1, field="interrupt_enable")

i2c.close()

Command-Based Device Query#

For command-based devices (like ADCs):

from instro.i2c.drivers.totalphase import Aardvark
from instro.i2c import I2CInterface
from system_definition import create_system_definition

# Import the shared system definition
system = create_system_definition()

i2c = I2CInterface(
    name="main_i2c",
    driver=Aardvark(serial_number="123456"),
    system_definition=system,
)

i2c.open()

# Query device (sends command and reads back scaled result)
voltage = i2c.query("adc", "read_ch0")
print(f"Channel 0 voltage: {voltage.latest}V")

# Query temperature sensor
temperature = i2c.query("temperature_sensor", "read_temperature")
print(f"Temperature: {temperature.latest}°C")

i2c.close()

Raw I2C Operations#

For advanced use cases, you can bypass the system definition and use raw I2C operations:

from instro.i2c.drivers.totalphase import Aardvark
from instro.i2c import I2CInterface
from system_definition import create_system_definition

# Import the shared system definition (still needed to create the instrument)
system = create_system_definition()

i2c = I2CInterface(
    name="main_i2c",
    driver=Aardvark(serial_number="123456"),
    system_definition=system,
)

i2c.open()

# Raw write
i2c.write_raw(address=0x20, data=b"\x01\xFF")

# Raw read
value = i2c.read_raw(address=0x20, length=2, endianness="little")

# Write-then-read (with stop condition between operations)
response = i2c.write_then_read_raw(
    address=0x48,
    payload=b"\x00",
    length=2,
    endianness="big"
)

# Write-read (no stop condition, typical for register reads)
register_value = i2c.write_read_raw(
    address=0x20,
    payload=b"\x01",  # Register address
    length=1,
    endianness="big"
)

i2c.close()

Note

Raw vs System Definition Methods

  • System definition methods (read(), write(), query()): Use device names and register aliases, handle data format conversion automatically

  • Raw methods (read_raw(), write_raw(), etc.): Direct I2C address and byte-level operations, no format conversion

Use system definition methods for most applications. Use raw methods only when you need direct control over I2C transactions.

Register Reset#

Reset a register to its default value as defined in the system definition:

i2c.reset_reg("gpio_expander", "CONFIG")

This is equivalent to writing the default_value specified in the RegisterDef.

Published channels#

Every read/write produces a channel keyed under {name}.{descriptor}, where {name} is the constructor argument and {descriptor} is built from the device names you defined in your SystemDefinition.

Method

Descriptor

Type

read(peripheral, register_alias)

{peripheral}.{register_alias}

telemetry

read(peripheral, register_alias, field=...)

{peripheral}.{register_alias}.{field}

telemetry

write(peripheral, register_alias, ...)

{peripheral}.{register_alias}.cmd

command

write(peripheral, register_alias, field=..., ...)

{peripheral}.{register_alias}.{field}.cmd

command

query(peripheral, batch_command)

{peripheral}.{batch_command}

telemetry

{peripheral}, {register_alias}, {field}, and {batch_command} are the names you assigned in your SystemDefinition. If you depend on the pre-v1.0 underscore-separator form (e.g. {name}_{peripheral}_{register} with a trailing _cmd), pass legacy_naming=True to the constructor.

Method Reference#

Method

Purpose

I2CInterface(name, driver, system_definition, publishers=None, legacy_naming=False, **kwargs)

Construct an I2CInterface instance composing a vendor driver

open()

Establish connection to the I2C adapter

close()

Disconnect from adapter and close all publishers

read(peripheral, register_alias, field="", **kwargs)

Read a register or field from a register-based device

write(peripheral, register_alias, value, field="", **kwargs)

Write to a register or field on a register-based device

reset_reg(peripheral, register_alias, **kwargs)

Reset a register to its default value

query(peripheral, batch_command, **kwargs)

Send command to a command-based device and read response

read_raw(address, length, endianness)

Perform raw I2C read operation

write_raw(address, data)

Perform raw I2C write operation

write_read_raw(address, payload, length, endianness)

Write then read without stop condition

write_then_read_raw(address, payload, length, endianness)

Write then read with stop condition between operations

start()

Begin background telemetry daemon

stop()

End background telemetry daemon

add_publisher(publisher)

Attach a publisher for data routing


Driver Development#

This section is for developers implementing I2CInterface support for I2C adapter vendors that are not supported out of the box.

Overview#

Driver developers implement the I2CDriverBase abstract interface to add support for new I2C adapter vendors. The driver is responsible for translating I2CInterface’s vendor-independent API calls into vendor-specific hardware operations, ensuring users get consistent behavior regardless of the underlying hardware.

Driver Responsibilities#

An I2C driver must:

  1. Hardware connection lifecycle: Implement open() and close() for establishing and terminating hardware connections

  2. Basic I2C operations: Implement read(), write(), and write_read() for fundamental I2C transactions

  3. Hardware configuration: Implement set_bitrate(), set_pullups(), and set_power_enable() for adapter configuration

  4. Resource management: Properly manage hardware resources and thread safety

I2CDriverBase Interface#

All I2C drivers must subclass I2CDriverBase and implement these abstract methods:

Required Methods#

def read(self, address: int, length: int) -> bytes:
    """Read bytes from an I2C device.

    Args:
        address: 7-bit I2C device address
        length: Number of bytes to read

    Returns:
        Bytes read from the device
    """

def write(self, address: int, data: bytes) -> None:
    """Write bytes to an I2C device.

    Args:
        address: 7-bit I2C device address
        data: Bytes to write to the device
    """

def write_read(self, address: int, data: bytes, read_len: int) -> bytes:
    """Write bytes then immediately read without stop condition.

    Performs a write operation followed by a read operation without
    issuing an I2C stop condition between them. Used for register reads.

    Args:
        address: 7-bit I2C device address
        data: Bytes to write (typically register address)
        read_len: Number of bytes to read back

    Returns:
        Bytes read from the device
    """

def set_bitrate(self, bitrate: int) -> None:
    """Set the I2C master clock rate in kHz.

    Args:
        bitrate: Desired bus clock rate in kHz (e.g., 100 for 100 kHz)
    """

def set_pullups(self, enable: bool) -> None:
    """Enable or disable I2C pull-up resistors.

    Args:
        enable: True to enable pull-ups, False to disable
    """

def set_power_enable(self, enable: bool) -> None:
    """Enable or disable power supply on I2C bus.

    Some adapters can provide power to I2C devices.

    Args:
        enable: True to enable power, False to disable
    """

def close(self) -> None:
    """Close the driver and release hardware resources."""

Driver Composition#

Concrete I2C drivers own their transport SDK. I2CInterface calls driver.open() / driver.close() and forwards I2C transactions to the driver. The driver does not need a back-reference to the instrument.

Implementation Example: Total Phase Driver#

Here’s a reference implementation for part of the Total Phase Aardvark driver:

from instro.i2c import I2CDriverBase

class Aardvark(I2CDriverBase):
    def __init__(self, serial_number: str | None = None) -> None:
        self._serial_number = serial_number
        self._device = None

    def open(self) -> None:
        import pyaardvark  # type: ignore

        self._device = pyaardvark.open(serial_number=self._serial_number)

    def close(self) -> None:
        if self._device is not None:
            self._device.close()
            self._device = None

    def read(self, address: int, length: int) -> bytes:
        return self._device.i2c_master_read(address, length)

    def write(self, address: int, data: bytes) -> None:
        self._device.i2c_master_write(address, data)

    def write_read(self, address: int, data: bytes, read_len: int) -> bytes:
        return self._device.i2c_master_write_read(address, data, read_len)
    ...
    # more implementations
    ...

Using Custom Drivers#

For custom drivers, construct I2CInterface directly and pass your driver instance:

from instro.i2c import I2CInterface, I2CDriverBase
from instro.i2c.types import SystemDefinition

class MyCustomI2CDriver(I2CDriverBase):
    """Custom driver for my lab's proprietary I2C adapter."""

    def open(self) -> None:
        # Open the underlying transport
        pass

    def close(self) -> None:
        # Release the underlying transport
        pass

    def read(self, address: int, length: int) -> bytes:
        # Custom implementation
        pass

    # ... implement other required methods ...

system = SystemDefinition()
# ... configure system definition ...

i2c = I2CInterface(
    name="custom_i2c",
    driver=MyCustomI2CDriver(device_id="<DEVICE_ID>"),
    system_definition=system,
)

i2c.open()
i2c.write("my_device", "REGISTER", 0xFF)
i2c.close()

Summary#

Driver development requires careful mapping of vendor-specific operations to the unified I2CDriverBase interface. Focus on:

  • Implementing all I2CDriverBase abstract methods

  • Handling 7-bit I2C addresses correctly (most vendors expect this)

  • Properly managing hardware resources (open/close)

  • Supporting both single and combined write-read operations

  • Testing with actual hardware to ensure commands work as expected