Skip to main content

System Definition

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 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)
Inverse Transformation Required for WritesIf 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
SystemDefinition ReusabilityYou 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.