blob: 82c095b7195aefcf8230b5802e88117595473d6a [file] [view]
# 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.
```rust
/// 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.
```rust
/// 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.
```rust
/// 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
```rust
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.
```rust
/// 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.
```rust
/// 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
```rust
/// 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
```rust
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
```rust
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
```rust
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)
```rust
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
```rust
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
```rust
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`:
```rust
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:
```rust
/// 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
```rust
/// 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
| 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 |
---
## Future Considerations
### Type-State for Target Mode
```rust
// 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
```rust
#[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
```rust
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
- [Hubris I2C Architecture Review](./hubris-i2c-architecture-review.md)
- [embedded-hal I2C traits](https://docs.rs/embedded-hal/latest/embedded_hal/i2c/)
- [Pigweed pw_i2c](https://pigweed.dev/pw_i2c/)