System Definition#

Understanding SystemDefinition for I2CInterface configuration

The SystemDefinition is a required configuration object that describes all I2C devices in your system, their registers, commands, and data formats. It serves as the bridge between your high-level application code and the low-level I2C hardware details.

Overview#

When you create a I2CInterface instrument, you must provide a SystemDefinition that describes:

  • All I2C devices on the bus (by name and I2C address)

  • For register-based devices: register maps, bit fields, and data formats

  • For command-based devices: command definitions and response formats

The SystemDefinition enables I2CInterface to:

  • Access devices and registers by human-readable names instead of raw addresses

  • Automatically handle bit field extraction and masking

  • Convert between raw register values and physical units using scaling functions

  • Validate and structure command operations

SystemDefinition Structure#

The SystemDefinition is a container that holds all device definitions:

from instro.i2c.types import SystemDefinition

system = SystemDefinition()

Adding Devices#

Add devices to the system definition using add_device():

system.add_device(register_device)
system.add_device(command_device)

Retrieving Devices#

Access devices by name when using I2CInterface methods:

device = system.device("my_device_name")

Device Types#

I2CInterface supports defining two types of I2C devices in the SystemDefinition.

  1. RegisterDevice - Devices that use register addressing (e.g., GPIO expanders, sensors with register maps)

  2. CommandDevice - Devices that respond to commands (e.g., ADCs that accept command bytes)

Both device types require:

  • name: Human-readable identifier used in I2CInterface method calls

  • address: 7-bit I2C address (0x00 to 0x7F)

Register-Based Devices#

RegisterDevice is used for devices that expose a register map, where each register has an address and can contain multiple bit fields.

RegisterDevice Configuration#

from instro.i2c.types import (
    DataFormat,
    FieldDef,
    RegisterDevice,
    RegisterDef,
)

device = RegisterDevice(
    name="power_gpio", # User defined name for the device
    address=0x20,  # 7-bit I2C address
    addr_width_bytes=1,  # Register address width (1 or 2 bytes)
    registers={
        "LED_OUTPUT_STATE": RegisterDef(
            alias="LED_OUTPUT_STATE",
            register=0x01,
            default_value=0x00,
            format=DataFormat(transfer_bits=8),
            endianness="big",
            fields={
                "led0": FieldDef(name="led0", lsb=0, width_bits=1),
                "led1": FieldDef(name="led1", lsb=1, width_bits=1),
            }
        )
    }
)

RegisterDevice Parameters#

  • name: Device identifier (used in i2c.read(), i2c.write(), etc.)

  • address: 7-bit I2C device address

  • addr_width_bytes: Number of bytes for register addresses (1 or 2). Most devices use 1 byte.

  • registers: Dictionary of RegisterDef objects keyed by register alias

Register Definitions#

Each register is defined with a RegisterDef. Registers can be simple (no bit fields) or contain multiple bit fields for accessing individual settings.

Example 1: Simple Register (No Bit Fields)#

A temperature sensor register that returns a 16-bit signed value:

from instro.i2c.types import DataFormat

RegisterDef(
    alias="TEMPERATURE",  # Human-readable name
    register=0x05,  # Register address
    default_value=0x0000,  # Power-on reset value
    format=DataFormat(transfer_bits=16, signed=True),  # 16-bit signed data
    endianness="big",  # "little" or "big" for multi-byte data
    # No fields - read/write the entire register value
)

Example 2: Register with Bit Fields#

A GPIO output register where each bit controls an LED:

from instro.i2c.types import DataFormat, FieldDef

RegisterDef(
    alias="LED_OUTPUT_STATE",  # Human-readable name
    register=0x01,  # Register address
    default_value=0x00,  # All LEDs off by default
    format=DataFormat(transfer_bits=8),  # 8-bit register
    endianness="big",
    fields={  # Each field represents one LED
        "led0": FieldDef(name="led0", lsb=0, width_bits=1),  # Bit 0
        "led1": FieldDef(name="led1", lsb=1, width_bits=1),  # Bit 1
        "led2": FieldDef(name="led2", lsb=2, width_bits=1),  # Bit 2
        "led3": FieldDef(name="led3", lsb=3, width_bits=1),  # Bit 3
        "led4": FieldDef(name="led4", lsb=4, width_bits=1),  # Bit 4
        "led5": FieldDef(name="led5", lsb=5, width_bits=1),  # Bit 5
        "led6": FieldDef(name="led6", lsb=6, width_bits=1),  # Bit 6
        "led7": FieldDef(name="led7", lsb=7, width_bits=1),  # Bit 7
    }
)

Bit Fields#

Bit fields allow you to access specific bits within a register without manually masking and shifting. Each field can span multiple bits if needed.

FieldDef Parameters:

  • name: Field identifier

  • lsb: Least significant bit position (0-indexed from the right)

  • width_bits: Number of bits in the field (default: 1)

Example with Multi-Bit Fields:

from instro.i2c.types import FieldDef

fields = {
    "enable": FieldDef(name="enable", lsb=0, width_bits=1),  # Single bit (Bit 0)
    "mode": FieldDef(name="mode", lsb=1, width_bits=2),  # Two bits (Bits [2:1])
    "rate": FieldDef(name="rate", lsb=3, width_bits=4),  # Four bits (Bits [6:3])
}

When reading or writing fields, I2CInterface automatically handles the masking and bit shifting:

# Read just a specific LED field
led3_state = i2c.read("gpio_expander", "LED_OUTPUT_STATE", field="led3")

# Write to a specific LED (read-modify-write operation)
i2c.write("gpio_expander", "LED_OUTPUT_STATE", 1, field="led0")  # Turn on LED 0

# Read the entire register (all LEDs at once)
all_leds = i2c.read("gpio_expander", "LED_OUTPUT_STATE")

Command-Based Devices#

CommandDevice is used for devices that respond to command bytes rather than register addressing (e.g., ADCs that accept channel selection commands).

CommandDevice Configuration#

from instro.i2c.types import CommandDevice, CommandDef, DataFormat
from enum import Enum

class ADCChannel(Enum):
    CH0 = 0x00
    CH1 = 0x10
    CH2 = 0x20

device = CommandDevice(
    name="adc",
    address=0x48,
    data_format=DataFormat(transfer_bits=16, signed=True),
    endianness="little",
    valid_commands={
        "channel": CommandDef(name="channel", values=ADCChannel)
    },
    batch_commands={
        "read_ch0": [ADCChannel.CH0],
        "read_ch1": [ADCChannel.CH1],
    }
)

CommandDevice Parameters#

  • name: Device identifier (used in i2c.query())

  • address: 7-bit I2C device address

  • data_format: DataFormat for command responses (required)

  • endianness: Byte order for multi-byte responses (“little” or “big”)

  • valid_commands: Dictionary of CommandDef objects defining command enums

  • batch_commands: Predefined combinations of commands (list of enum values)

Batch Commands#

Batch commands allow you to combine multiple command enum values using OR operations:

# Multiple commands OR'd together automatically
batch_commands = {
    "read_ch0_fast": [ADCChannel.CH0, FastMode.ENABLE],
}

When you call i2c.query("adc", "read_ch0_fast"), I2CInterface automatically ORs ADCChannel.CH0 | FastMode.ENABLE and sends the result.

Data Format#

The DataFormat class defines how raw I2C data is interpreted, extracted, and scaled to physical units.

DataFormat Overview#

from instro.i2c.types import DataFormat, LinearScaling

format = DataFormat(
    transfer_bits=16,  # Total bits transferred over I2C
    data_width_bits=12,  # Logical data bits (if different from transfer_bits)
    data_lsb=4,  # Starting bit position of logical data
    signed=True,  # Whether data is signed
    scaling=LinearScaling(gain=0.1, offset=-40.0),  # Scaling function
    units="°C"  # Physical units
)

DataFormat Parameters#

  • transfer_bits: Total bits transferred in the I2C transaction (must be multiple of 8)

  • data_width_bits: Logical data width if different from transfer bits (None = use all transfer bits)

  • data_lsb: Starting bit position of logical data (default: 0)

  • signed: Whether to interpret data as signed (2’s complement) (default: False)

  • scaling: Optional ScalingFunction to convert raw values to physical units

  • units: Physical units string (default: “”)

Data Extraction#

The DataFormat handles several data extraction scenarios:

1. Full Transfer Width (no extraction)

# 16-bit register, all bits are data
DataFormat(transfer_bits=16)

2. Subset of Transfer Bits

# 16-bit transfer, but only bits [11:4] contain data
DataFormat(transfer_bits=16, data_width_bits=8, data_lsb=4)

3. Signed Data

# 12-bit signed value (range: -2048 to 2047)
DataFormat(transfer_bits=16, data_width_bits=12, signed=True)

Scaling Functions#

Scaling functions convert raw integer values to physical units and vice versa.

Linear Scaling#

Most sensors use linear scaling:

from instro.i2c.types import LinearScaling

# Temperature sensor: 0.1°C per count, -40°C offset
# Raw value 500 → (500 * 0.1) - 40 = 10.0°C
scaling = LinearScaling(gain=0.1, offset=-40.0)

Formula: physical = offset + gain × raw

Custom Scaling#

For non-linear sensors or complex conversions:

from instro.i2c.types import CustomScaling

# ADC with voltage divider: V = (ADC_value / 4095) * 5.0 * 7.2
scaling = CustomScaling(
    to_physical_fn=lambda x: x / 4095 * 5.0 * 7.2,
    to_raw_fn=lambda v: int(v / 7.2 / 5.0 * 4095)
)

CustomScaling Parameters:

  • to_physical_fn: Function to convert raw integer → physical float

  • to_raw_fn: Optional function to convert physical float → raw integer (required for write operations)

Warning

Inverse Transformation Required for Writes

If you plan to write values to registers that use custom scaling, you must provide to_raw_fn. Otherwise, NotImplementedError will be raised when attempting to convert physical values back to raw.

Complete Examples#

Example 1: GPIO Expander (RegisterDevice)#

A common use case: configuring a GPIO expander with multiple registers and bit fields.

from instro.i2c.types import (
    SystemDefinition,
    RegisterDevice,
    RegisterDef,
    FieldDef,
    DataFormat,
)

system = SystemDefinition()

# GPIO Expander at address 0x20
gpio = RegisterDevice(
    name="gpio_expander",
    address=0x20,
    addr_width_bytes=1,
    registers={
        "IODIR": RegisterDef(
            alias="IODIR",  # I/O Direction register
            register=0x00,
            default_value=0xFF,  # All inputs by default
            format=DataFormat(transfer_bits=8),
            fields={
                "port0": FieldDef(name="port0", lsb=0, width_bits=1),
                "port1": FieldDef(name="port1", lsb=1, width_bits=1),
                # ... more ports
            }
        ),
        "GPIO": RegisterDef(
            alias="GPIO",  # GPIO Port register
            register=0x09,
            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),
                "interrupt_mirror": FieldDef(name="interrupt_mirror", lsb=3, width_bits=1),
            }
        ),
    }
)

system.add_device(gpio)

Usage:

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

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

i2c.open()

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

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

# Read full register
state = i2c.read("gpio_expander", "GPIO")

# Read specific field
interrupt_enabled = i2c.read("gpio_expander", "CONFIG", field="interrupt_enable")

Example 2: Temperature Sensor (CommandDevice)#

A command-based ADC that reads temperature when sent a command.

from instro.i2c.types import (
    SystemDefinition,
    CommandDevice,
    CommandDef,
    DataFormat,
    LinearScaling,
)
from enum import Enum

class TempCommand(Enum):
    START_CONVERSION = 0x01
    READ_RESULT = 0x02

system = SystemDefinition()

# Temperature sensor at address 0x48
temp_sensor = CommandDevice(
    name="temperature_sensor",
    address=0x48,
    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)

Usage:

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

i2c.open()

# Query temperature (sends command and reads back scaled result)
measurement = i2c.query("temperature_sensor", "read_temperature")
print(f"Temperature: {measurement.latest}°C")

Example 3: Mixed System#

A system with both register-based and command-based devices:

system = SystemDefinition()

# Register-based: GPIO expander
gpio = RegisterDevice(
    name="gpio",
    address=0x20,
    registers={
        "OUTPUT": RegisterDef(
            alias="OUTPUT",
            register=0x01,
            format=DataFormat(transfer_bits=8),
        )
    }
)

# Command-based: ADC
class ADCChannel(Enum):
    CH0 = 0x00
    CH1 = 0x01

adc = CommandDevice(
    name="adc",
    address=0x48,
    data_format=DataFormat(transfer_bits=16, signed=True),
    batch_commands={
        "read_ch0": [ADCChannel.CH0],
        "read_ch1": [ADCChannel.CH1],
    }
)

system.add_device(gpio)
system.add_device(adc)

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

i2c.open()

# Register-based operations
i2c.write("gpio", "OUTPUT", 0xFF)
state = i2c.read("gpio", "OUTPUT")

# Command-based operations
voltage = i2c.query("adc", "read_ch0")

Best Practices#

  1. Use descriptive names: Device and register names should be clear and match your hardware documentation

  2. Define all registers: Include all registers you’ll access, even if not all fields are defined

  3. Use field definitions: For registers with multiple settings, define fields to simplify read-modify-write operations

  4. Set default values: Define default_value for registers to enable reset_reg() functionality

  5. Use scaling functions: Define scaling for all sensor data to work in physical units

  6. Validate addresses: Ensure I2C addresses are 7-bit (0x00-0x7F) and don’t conflict

  7. Document units: Always specify units in DataFormat for clarity

Tip

SystemDefinition Reusability

You can reuse a SystemDefinition across multiple I2CInterface instances if they share the same I2C bus configuration. Create the definition once and pass it to multiple instruments.