Version: 0.1.0
Date: February 15, 2026
Status: Draft
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.
┌─────────────────────────────────────────────────────────────────┐
│ 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) │
└─────────────────────────────────────────────────────────────────┘
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; }
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); }
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, } } }
Traits follow the embedded_hal::i2c::ErrorType pattern, separating error type definition from behavior traits for maximum composability.
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; }
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]), }
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 {}
For protocols like MCTP that require responding to incoming transactions.
/// 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, } } }
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 │
└────────────────────────────────────────────────────────────────┘
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) }
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(()) }
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) }
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())?; } } }
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) }
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); } } } } }
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 */ } } }
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++.
/// 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(()) } }
| Aspect | Hubris | Pigweed (This Design) |
|---|---|---|
| Address handling | Raw u8 | I2cAddress newtype |
| Bus routing | Embedded in I2cDevice | Separate BusIndex parameter |
| Method count | 11+ variants | 3 core + convenience |
| Error type | ResponseCode (30 variants) | I2cError (15 codes) |
| Target mode | Runtime state checks | Same (consider type-state later) |
| Generics | IntoBytes + FromBytes | AsRef<[u8]> / concrete |
// 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_trait] pub trait I2cClientAsync { async fn write_read( &mut self, bus: BusIndex, address: I2cAddress, write: &[u8], read: &mut [u8], ) -> Result<usize, Self::Error>; }
pub struct MuxedBus { bus: BusIndex, mux_address: I2cAddress, channel: u8, } impl MuxedBus { fn select(&self, client: &mut impl I2cClient) -> Result<(), I2cError>; }
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