I2C Client API Design

Version: 0.1.0
Date: February 15, 2026
Status: Draft


Overview

This document defines the I2C client API for OpenPRoT Pigweed. The API provides type-safe, ergonomic access to I2C devices through an IPC-based server architecture.

Design Goals

  1. Type Safety: Validated addresses, explicit error handling
  2. Ergonomic: Builder patterns, reduced API surface
  3. Portable: Transport-agnostic core, IPC details at boundary
  4. Efficient: Zero-copy where possible, minimal allocations
  5. Testable: Trait-based design enables mocking

Architecture

┌─────────────────────────────────────────────────────────────────┐
│                      Application Code                           │
│            (sensor drivers, MCTP handlers, etc.)                │
└─────────────────────────────────────────────────────────────────┘
                              │
                              │ uses I2cClient trait
                              ▼
┌─────────────────────────────────────────────────────────────────┐
│                        i2c-api crate                            │
│  ┌─────────────┐  ┌─────────────┐  ┌─────────────────────────┐  │
│  │ I2cAddress  │  │ I2cError    │  │ I2cClient trait         │  │
│  │ BusIndex    │  │ ResponseCode│  │ I2cClientBlocking trait │  │
│  └─────────────┘  └─────────────┘  └─────────────────────────┘  │
└─────────────────────────────────────────────────────────────────┘
                              │
                              │ IPC (transport-specific)
                              ▼
┌─────────────────────────────────────────────────────────────────┐
│                      I2C Server Task                            │
│                   (hardware access)                             │
└─────────────────────────────────────────────────────────────────┘

Core Types

I2cAddress

A validated 7-bit I2C address.

/// A validated 7-bit I2C address.
///
/// I2C addresses are 7 bits (0x00-0x7F), with reserved ranges:
/// - 0x00-0x07: Reserved (general call, CBUS, etc.)
/// - 0x78-0x7F: Reserved (10-bit addressing, device ID)
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
pub struct I2cAddress(u8);

impl I2cAddress {
    /// Creates a validated address, rejecting reserved ranges.
    pub const fn new(addr: u8) -> Result<Self, AddressError>;
    
    /// Creates an address without validation (for reserved addresses).
    pub const fn new_unchecked(addr: u8) -> Self;
    
    /// Returns the raw 7-bit address.
    pub const fn value(self) -> u8;
    
    /// Returns address formatted for wire (shifted left, R/W bit space).
    pub const fn write_address(self) -> u8;
    pub const fn read_address(self) -> u8;
}

impl TryFrom<u8> for I2cAddress {
    type Error = AddressError;
}

BusIndex

Identifies an I2C bus/controller.

/// I2C bus identifier.
///
/// Each bus represents a physical I2C controller or a logical bus
/// behind a multiplexer.
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
pub struct BusIndex(u8);

impl BusIndex {
    /// Creates a new bus index.
    pub const fn new(index: u8) -> Self;
    
    /// Returns the raw index value.
    pub const fn value(self) -> u8;
}

// Common bus indices as constants
impl BusIndex {
    pub const BUS_0: BusIndex = BusIndex(0);
    pub const BUS_1: BusIndex = BusIndex(1);
    pub const BUS_2: BusIndex = BusIndex(2);
}

I2cError

Transport-agnostic error type.

/// I2C operation error.
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub struct I2cError {
    /// High-level error classification.
    pub code: ResponseCode,
    /// Hardware-level error kind (for diagnostics).
    pub kind: Option<I2cErrorKind>,
}

/// Response codes from I2C operations.
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
#[repr(u8)]
pub enum ResponseCode {
    Success = 0,
    NoDevice = 1,        // NACK on address
    NackData = 2,        // NACK during data
    ArbitrationLost = 3, // Lost bus arbitration
    BusStuck = 4,        // SDA/SCL stuck low
    Timeout = 5,         // Operation timed out
    InvalidBus = 6,      // Bad bus index
    InvalidAddress = 7,  // Bad address
    BufferTooSmall = 8,  // Read buffer insufficient
    BufferTooLarge = 9,  // Write exceeds limit
    NotInitialized = 10, // Controller not ready
    Busy = 11,           // Controller busy
    Unauthorized = 12,   // Permission denied
    IoError = 13,        // General I/O error
    ServerError = 14,    // Internal server error
}

/// Low-level error classification (compatible with embedded-hal).
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum I2cErrorKind {
    Bus,
    ArbitrationLoss,
    NoAcknowledge(NoAcknowledgeSource),
    Overrun,
    Other,
}

/// Implement embedded-hal Error trait for compatibility.
impl embedded_hal::i2c::Error for I2cError {
    fn kind(&self) -> embedded_hal::i2c::ErrorKind {
        match self.kind {
            Some(I2cErrorKind::Bus) => ErrorKind::Bus,
            Some(I2cErrorKind::ArbitrationLoss) => ErrorKind::ArbitrationLoss,
            Some(I2cErrorKind::NoAcknowledge(src)) => ErrorKind::NoAcknowledge(src),
            Some(I2cErrorKind::Overrun) => ErrorKind::Overrun,
            _ => ErrorKind::Other,
        }
    }
}

Client Traits

Traits follow the embedded_hal::i2c::ErrorType pattern, separating error type definition from behavior traits for maximum composability.

ErrorType Pattern

use embedded_hal::i2c::ErrorType;

/// Alias for I2C error type constraint.
/// Error types must implement embedded-hal's Error trait.
pub trait I2cErrorType {
    /// Error type returned by I2C operations.
    type Error: embedded_hal::i2c::Error + core::fmt::Debug;
}

I2cClient (Core)

The fundamental I2C client trait providing basic operations.

/// Core I2C client operations.
///
/// This trait defines the fundamental I2C operations without
/// transport-specific details. Implementations handle IPC,
/// direct hardware access, or mocking.
///
/// Uses the `ErrorType` supertrait pattern from embedded-hal.
pub trait I2cClient: I2cErrorType {
    /// Write data to a device, then read response.
    ///
    /// This is the fundamental I2C operation:
    /// - If `write` is non-empty and `read` is empty: write-only
    /// - If `write` is empty and `read` is non-empty: read-only
    /// - If both non-empty: write-then-read (repeated start)
    ///
    /// # Arguments
    ///
    /// * `bus` - I2C bus to use
    /// * `address` - Device address
    /// * `write` - Data to write (can be empty)
    /// * `read` - Buffer for read data (can be empty)
    ///
    /// # Returns
    ///
    /// Number of bytes read on success.
    fn write_read(
        &mut self,
        bus: BusIndex,
        address: I2cAddress,
        write: &[u8],
        read: &mut [u8],
    ) -> Result<usize, Self::Error>;

    /// Execute multiple operations as a single transaction.
    ///
    /// All operations execute atomically without releasing the bus.
    fn transaction(
        &mut self,
        bus: BusIndex,
        address: I2cAddress,
        operations: &mut [Operation<'_>],
    ) -> Result<(), Self::Error>;
}

/// I2C operation for transaction sequences.
#[derive(Debug)]
pub enum Operation<'a> {
    /// Write data to device.
    Write(&'a [u8]),
    /// Read data from device.
    Read(&'a mut [u8]),
}

I2cClientBlocking (Convenience)

Extended trait with blocking convenience methods.

/// Blocking I2C client with convenience methods.
///
/// Extends `I2cClient` with higher-level operations commonly
/// used with register-based I2C devices.
pub trait I2cClientBlocking: I2cClient {
    /// Write data to a device.
    fn write(
        &mut self,
        bus: BusIndex,
        address: I2cAddress,
        data: &[u8],
    ) -> Result<(), Self::Error> {
        self.write_read(bus, address, data, &mut [])?;
        Ok(())
    }

    /// Read data from a device.
    fn read(
        &mut self,
        bus: BusIndex,
        address: I2cAddress,
        buffer: &mut [u8],
    ) -> Result<usize, Self::Error> {
        self.write_read(bus, address, &[], buffer)
    }

    /// Read a register value.
    ///
    /// Writes the register address, then reads the value.
    fn read_register<R: AsRef<[u8]>, V: AsMut<[u8]> + Default>(
        &mut self,
        bus: BusIndex,
        address: I2cAddress,
        register: R,
    ) -> Result<V, Self::Error> {
        let mut value = V::default();
        self.write_read(bus, address, register.as_ref(), value.as_mut())?;
        Ok(value)
    }

    /// Write a register value.
    ///
    /// Writes register address followed by value in single transaction.
    fn write_register<R: AsRef<[u8]>, V: AsRef<[u8]>>(
        &mut self,
        bus: BusIndex,
        address: I2cAddress,
        register: R,
        value: V,
    ) -> Result<(), Self::Error> {
        // Combine register and value into single write
        self.transaction(bus, address, &mut [
            Operation::Write(register.as_ref()),
            Operation::Write(value.as_ref()),
        ])
    }

    /// Check if a device is present at the given address.
    ///
    /// Performs a zero-length write to probe for ACK.
    fn probe(
        &mut self,
        bus: BusIndex,
        address: I2cAddress,
    ) -> Result<bool, Self::Error> {
        use embedded_hal::i2c::{Error, ErrorKind};
        
        match self.write(bus, address, &[]) {
            Ok(()) => Ok(true),
            Err(e) => {
                // Use embedded-hal Error trait to check error kind
                if matches!(e.kind(), ErrorKind::NoAcknowledge(_)) {
                    Ok(false)
                } else {
                    Err(e)
                }
            }
        }
    }
}

// Blanket implementation for all I2cClient implementors
impl<T: I2cClient> I2cClientBlocking for T {}

Target Mode API

For protocols like MCTP that require responding to incoming transactions.

TargetClient Trait

/// I2C target mode operations.
///
/// Allows the device to respond to I2C transactions initiated
/// by other controllers on the bus. Uses notification-based message
/// delivery rather than polling.
///
/// Uses the `ErrorType` supertrait pattern from embedded-hal.
pub trait I2cTargetClient: I2cErrorType {
    /// Configure this controller to respond at the given address.
    fn configure_target_address(
        &mut self,
        bus: BusIndex,
        address: I2cAddress,
    ) -> Result<(), Self::Error>;

    /// Enable target receive mode.
    ///
    /// After this call, incoming transactions to the configured
    /// address will trigger notifications.
    fn enable_receive(&mut self, bus: BusIndex) -> Result<(), Self::Error>;

    /// Disable target receive mode.
    fn disable_receive(&mut self, bus: BusIndex) -> Result<(), Self::Error>;

    /// Wait for incoming target messages.
    ///
    /// Blocks until one or more messages are available, or timeout expires.
    /// Returns the number of messages retrieved.
    fn wait_for_messages(
        &mut self,
        bus: BusIndex,
        messages: &mut [TargetMessage],
        timeout: Option<Duration>,
    ) -> Result<usize, Self::Error>;

    /// Register a notification callback for incoming messages.
    ///
    /// When a target message arrives, the kernel will post a notification
    /// to the calling task. The task can then call `get_pending_messages`
    /// to retrieve the buffered data.
    fn register_notification(
        &mut self,
        bus: BusIndex,
        notification_mask: u32,
    ) -> Result<(), Self::Error>;

    /// Retrieve pending messages after receiving a notification.
    ///
    /// Call this after receiving a target message notification.
    /// Returns the number of messages retrieved.
    fn get_pending_messages(
        &mut self,
        bus: BusIndex,
        messages: &mut [TargetMessage],
    ) -> Result<usize, Self::Error>;
}

/// A message received in target mode.
#[derive(Debug, Clone)]
pub struct TargetMessage {
    /// Address of the controller that sent this message.
    pub source_address: I2cAddress,
    /// Message data (up to 255 bytes).
    data: [u8; 255],
    /// Actual length of data.
    len: u8,
}

impl TargetMessage {
    /// Returns the message data.
    pub fn data(&self) -> &[u8] {
        &self.data[..self.len as usize]
    }
    
    /// Returns the message length.
    pub fn len(&self) -> usize {
        self.len as usize
    }
    
    /// Returns true if message is empty.
    pub fn is_empty(&self) -> bool {
        self.len == 0
    }
}

impl Default for TargetMessage {
    fn default() -> Self {
        Self {
            source_address: I2cAddress::new_unchecked(0),
            data: [0u8; 255],
            len: 0,
        }
    }
}

Notification-Based Flow

Target mode uses hardware interrupts delivered as task notifications:

┌────────────────────────────────────────────────────────────────┐
│  External I2C controller sends data to our target address     │
└────────────────────────────────────────────────────────────────┘
                              │
                              ▼
┌────────────────────────────────────────────────────────────────┐
│  I2C Hardware generates IRQ                                    │
└────────────────────────────────────────────────────────────────┘
                              │
                              ▼
┌────────────────────────────────────────────────────────────────┐
│  Kernel delivers notification to I2C server task               │
└────────────────────────────────────────────────────────────────┘
                              │
                              ▼
┌────────────────────────────────────────────────────────────────┐
│  Server buffers message, posts notification to client task     │
└────────────────────────────────────────────────────────────────┘
                              │
                              ▼
┌────────────────────────────────────────────────────────────────┐
│  Client calls get_pending_messages() to retrieve data          │
└────────────────────────────────────────────────────────────────┘

Usage Examples

Basic Device Access

use i2c_api::{I2cClient, I2cClientBlocking, I2cAddress, BusIndex};

fn read_temperature<C: I2cClient>(
    client: &mut C,
) -> Result<f32, C::Error> {
    let bus = BusIndex::BUS_0;
    let address = I2cAddress::new(0x48).expect("valid address");
    
    // Read 2-byte temperature register at offset 0x00
    let mut buffer = [0u8; 2];
    client.write_read(bus, address, &[0x00], &mut buffer)?;
    
    // Convert to temperature (device-specific)
    let raw = i16::from_be_bytes(buffer);
    Ok(raw as f32 * 0.0625)
}

Register-Based Device

use i2c_api::{I2cClientBlocking, I2cAddress, BusIndex};

fn configure_sensor<C: I2cClientBlocking>(
    client: &mut C,
) -> Result<(), C::Error> {
    let bus = BusIndex::BUS_1;
    let address = I2cAddress::new(0x76)?;
    
    // Write configuration register
    client.write_register(bus, address, [0xF4], [0x27])?;
    
    // Read status register
    let status: [u8; 1] = client.read_register(bus, address, [0xF3])?;
    
    Ok(())
}

Multi-Operation Transaction

use i2c_api::{I2cClient, Operation, I2cAddress, BusIndex};

fn atomic_read_write<C: I2cClient>(
    client: &mut C,
) -> Result<[u8; 4], C::Error> {
    let bus = BusIndex::BUS_0;
    let address = I2cAddress::new(0x50)?;
    
    let command = [0x10, 0x20];
    let mut response = [0u8; 4];
    
    // Execute as single atomic transaction
    client.transaction(bus, address, &mut [
        Operation::Write(&command),
        Operation::Read(&mut response),
    ])?;
    
    Ok(response)
}

Target Mode (MCTP)

use i2c_api::{I2cTargetClient, I2cAddress, BusIndex, TargetMessage};
use core::time::Duration;

const TARGET_MSG_NOTIFICATION: u32 = 1 << 4;

fn mctp_handler<C: I2cTargetClient>(
    client: &mut C,
) -> Result<(), C::Error> {
    let bus = BusIndex::BUS_2;
    let our_address = I2cAddress::new(0x1D)?;
    
    // Configure and enable target mode with notifications
    client.configure_target_address(bus, our_address)?;
    client.register_notification(bus, TARGET_MSG_NOTIFICATION)?;
    client.enable_receive(bus)?;
    
    let mut messages = [TargetMessage::default(); 4];
    
    loop {
        // Wait for notification from kernel (blocks until message arrives)
        sys_recv_notification(TARGET_MSG_NOTIFICATION);
        
        // Retrieve all pending messages
        let count = client.get_pending_messages(bus, &mut messages)?;
        
        for msg in &messages[..count] {
            process_mctp_message(msg.source_address, msg.data())?;
        }
    }
}

// Alternative: blocking wait with timeout
fn mctp_handler_blocking<C: I2cTargetClient>(
    client: &mut C,
) -> Result<(), C::Error> {
    let bus = BusIndex::BUS_2;
    let our_address = I2cAddress::new(0x1D)?;
    
    client.configure_target_address(bus, our_address)?;
    client.enable_receive(bus)?;
    
    let mut messages = [TargetMessage::default(); 4];
    
    loop {
        // Block waiting for messages (up to 1 second)
        let count = client.wait_for_messages(
            bus,
            &mut messages,
            Some(Duration::from_secs(1)),
        )?;
        
        for msg in &messages[..count] {
            process_mctp_message(msg.source_address, msg.data())?;
        }
    }
}

Device Probing

use i2c_api::{I2cClientBlocking, I2cAddress, BusIndex};

fn scan_bus<C: I2cClientBlocking>(
    client: &mut C,
    bus: BusIndex,
) -> Result<Vec<I2cAddress>, C::Error> {
    let mut found = Vec::new();
    
    for addr in 0x08..0x78 {
        if let Ok(address) = I2cAddress::new(addr) {
            if client.probe(bus, address)? {
                found.push(address);
            }
        }
    }
    
    Ok(found)
}

Error Handling

Patterns

use i2c_api::{I2cClient, I2cErrorType};
use embedded_hal::i2c::{Error, ErrorKind};

fn handle_errors<C: I2cClient>(client: &mut C, bus: BusIndex, addr: I2cAddress) {
    let data = [0x00];
    let mut buf = [0u8; 4];
    
    match client.write_read(bus, addr, &data, &mut buf) {
        Ok(n) => println!("Read {} bytes", n),
        
        Err(e) => {
            // Use embedded-hal Error trait for portable error handling
            match e.kind() {
                ErrorKind::NoAcknowledge(_) => {
                    println!("Device not present");
                }
                ErrorKind::ArbitrationLoss => {
                    println!("Lost bus arbitration");
                }
                ErrorKind::Bus => {
                    // Attempt recovery
                    // client.recover_bus(bus);
                }
                _ => {
                    println!("Error: {:?}", e);
                }
            }
        }
    }
}

Error Type with ResponseCode

For application-specific error handling, downcast to I2cError:

use i2c_api::{I2cError, ResponseCode};

fn handle_response_code(err: &I2cError) {
    match err.code {
        ResponseCode::NoDevice => { /* ... */ }
        ResponseCode::Timeout => { /* ... */ }
        ResponseCode::BusStuck => { /* ... */ }
        ResponseCode::Unauthorized => { /* permission error */ }
        _ => { /* other */ }
    }
}

Implementation Notes

IPC Integration

The client traits are transport-agnostic. The IPC implementation depends on the kernel:

/// Pigweed kernel channel-based I2C client.
/// 
/// Uses pw_channel for communication with the I2C server task.
pub struct I2cChannelClient {
    /// Channel endpoint for sending requests to I2C server
    server_channel: pw_channel::Channel,
}

/// Implement the error type trait (required by I2cClient supertrait).
impl I2cErrorType for I2cChannelClient {
    type Error = I2cError;
}

impl I2cClient for I2cChannelClient {

    fn write_read(
        &mut self,
        bus: BusIndex,
        address: I2cAddress,
        write: &[u8],
        read: &mut [u8],
    ) -> Result<usize, Self::Error> {
        // Encode request into channel message
        // Format: [op, bus, addr, write_len, write_data..., read_len]
        let mut request = [0u8; 256];
        request[0] = OP_WRITE_READ;
        request[1] = bus.value();
        request[2] = address.value();
        request[3] = write.len() as u8;
        request[4..4 + write.len()].copy_from_slice(write);
        request[4 + write.len()] = read.len() as u8;
        
        let request_len = 5 + write.len();
        
        // Send request and wait for response
        self.server_channel.write(&request[..request_len])?;
        
        // Read response: [status, data...]
        let mut response = [0u8; 256];
        let response_len = self.server_channel.read(&mut response)?;
        
        // Check status
        let status = ResponseCode::from_u8(response[0])
            .ok_or(I2cError::from_code(ResponseCode::ServerError))?;
        
        if status != ResponseCode::Success {
            return Err(I2cError::from_code(status));
        }
        
        // Copy response data
        let data_len = (response_len - 1).min(read.len());
        read[..data_len].copy_from_slice(&response[1..1 + data_len]);
        
        Ok(data_len)
    }
    
    fn transaction(
        &mut self,
        bus: BusIndex,
        address: I2cAddress,
        operations: &mut [Operation<'_>],
    ) -> Result<(), Self::Error> {
        // Encode transaction as sequence of operations
        // ... implementation details
        todo!()
    }
}

Note: The actual IPC mechanism will use Pigweed‘s Rust channel primitives (pw_channel) or the kernel’s native message passing, not pw_rpc which is C++.

Testing

/// Mock client for testing.
pub struct MockI2cClient {
    expected_calls: Vec<ExpectedCall>,
    call_index: usize,
}

/// Implement the error type trait.
impl I2cErrorType for MockI2cClient {
    type Error = I2cError;
}

impl I2cClient for MockI2cClient {
    fn write_read(
        &mut self,
        bus: BusIndex,
        address: I2cAddress,
        write: &[u8],
        read: &mut [u8],
    ) -> Result<usize, Self::Error> {
        let expected = &self.expected_calls[self.call_index];
        self.call_index += 1;
        
        assert_eq!(address, expected.address);
        assert_eq!(write, expected.write_data);
        
        let len = expected.response.len().min(read.len());
        read[..len].copy_from_slice(&expected.response[..len]);
        
        expected.result.clone()
    }
    
    fn transaction(
        &mut self,
        _bus: BusIndex,
        _address: I2cAddress,
        _operations: &mut [Operation<'_>],
    ) -> Result<(), Self::Error> {
        // Transaction mock implementation
        Ok(())
    }
}

Comparison with Hubris API

AspectHubrisPigweed (This Design)
Address handlingRaw u8I2cAddress newtype
Bus routingEmbedded in I2cDeviceSeparate BusIndex parameter
Method count11+ variants3 core + convenience
Error typeResponseCode (30 variants)I2cError (15 codes)
Target modeRuntime state checksSame (consider type-state later)
GenericsIntoBytes + FromBytesAsRef<[u8]> / concrete

Future Considerations

Type-State for Target Mode

// Potential future API with compile-time state tracking
let configured = client.configure_target(bus, address)?;
let receiving = configured.enable_receive()?;
let messages = receiving.wait()?;

Async Support

#[async_trait]
pub trait I2cClientAsync {
    async fn write_read(
        &mut self,
        bus: BusIndex,
        address: I2cAddress,
        write: &[u8],
        read: &mut [u8],
    ) -> Result<usize, Self::Error>;
}

Bus Multiplexer Support

pub struct MuxedBus {
    bus: BusIndex,
    mux_address: I2cAddress,
    channel: u8,
}

impl MuxedBus {
    fn select(&self, client: &mut impl I2cClient) -> Result<(), I2cError>;
}

File Structure

services/i2c/api/
├── Cargo.toml
├── BUILD.bazel
└── src/
    ├── lib.rs          # Re-exports
    ├── address.rs      # I2cAddress, AddressError
    ├── error.rs        # I2cError, ResponseCode, I2cErrorKind
    ├── client.rs       # I2cClient, I2cClientBlocking traits
    ├── target.rs       # I2cTargetClient, TargetMessage
    └── operation.rs    # Operation enum

References