Author: Claude (AI Architectural Review)
Date: February 15, 2026
Status: Design Analysis for Pigweed Port
The Hubris I2C design represents a production-quality embedded systems architecture that prioritizes correctness, hardware ownership, and portability. This review identifies strengths to preserve and areas where Rust ergonomics can be improved for the Pigweed port.
Overall Grade: B+ (Strong Foundation)
Grade: B+
Clean 3-layer separation:
i2c_core (portable hardware driver)drv-ast1060-i2c (Hubris wrapper implementing I2cHardware)drv-openprot-i2c-server (vendor-agnostic IPC server)Trait-based abstraction: The I2cHardware trait enables vendor-agnostic server logic
Compile-time hardware selection: Feature flags (#[cfg(feature = "ast1060")]) select vendor implementations with zero runtime overhead
Tight coupling in I2cDevice: The client API embeds TaskId, Controller, Port, Mux, Segment, and address into a single struct. Every operation carries the entire routing context:
// Current design - everything bundled together pub struct I2cDevice { pub task: TaskId, pub controller: Controller, pub port: PortIndex, pub segment: Option<(Mux, Segment)>, pub address: u8, }
Consider splitting I2cDevice into composable parts:
// Routing information (could be shared across devices) pub struct I2cBus { pub task: TaskId, pub controller: Controller, pub port: PortIndex, pub segment: Option<(Mux, Segment)>, } // Validated address pub struct I2cAddress(u8); // Device operations take both impl I2cBus { fn write_read(&self, addr: I2cAddress, ...) -> Result<...>; }
Grade: A-
i2c_core is #![no_std] with no alloc requirementmain.rs vs. runtime driver operationsembedded-hal trait implementation in core driverResponseCode leakage: The ResponseCode enum has ~30 variants, some quite Hubris-specific:
BusLockedMuxSegmentDisconnectedMuxMissingThese make sense in Hubris's multiplexer-heavy topology but add noise for simpler deployments.
For Pigweed port:
Grade: A
from_initialized() pattern: Avoids ~50 register writes per operation by assuming hardware was pre-configured
Zero-copy lease handling: Server uses sys_borrow_read/sys_borrow_write for efficient data transfer
Appropriate mode selection:
The polling-based master mode is correct for most embedded use cases:
Each write_read() creates a temporary Ast1060I2c struct. While cheap with from_initialized(), consider if this pattern should be maintained or if a more persistent driver handle is appropriate for Pigweed.
Grade: B
API surface explosion: The client API has many method variants:
| Read Operations | Write Operations |
|---|---|
read_reg | write |
read_reg_into | write_read_reg |
read | write_read_block |
read_into | write_write |
read_block | write_write_read_reg |
This creates:
Zerocopy complexity: Generic bounds create complex signatures:
fn read_reg<R, V>(&self, reg: R) -> Result<V, ResponseCode> where R: IntoBytes + Immutable, V: IntoBytes + FromBytes,
Error messages become opaque when bounds aren't satisfied.
Consider transaction builder pattern for Pigweed:
// More composable, fewer methods client.transaction(address) .write(®_bytes) .read(&mut buffer) .execute()?; // Or with operations list client.execute(address, &mut [ Operation::Write(&cmd), Operation::Read(&mut response), ])?;
Grade: B-
// Current - raw u8, no compile-time validation configure_slave_address(0x48)?; // What if user passes 0x00? // Better - newtype with validation configure_slave_address(I2cAddress::try_from(0x48)?)?;
// Current - panics on invalid address in some paths let device = I2cDevice::new(task, controller, port, None, 0x48); // Better - builder with explicit error handling let device = I2cDevice::builder() .controller(Controller::I2C1) .address(I2cAddress::new(0x48)?) .build(task)?;
// Current - runtime checks for state ordering enable_slave_receive()?; // Must call configure_slave_address first! // Better - type-state ensures ordering at compile time let configured: SlaveConfigured = client.configure_slave_address(0x1D)?; let receiving: SlaveReceiving = configured.enable_receive()?;
When multiple generic parameters have bounds, the trait bounds section grows:
fn write_write_read_reg<R, V>( &self, reg: R, first: &[u8], second: &[u8], ) -> Result<V, ResponseCode> where R: IntoBytes + Immutable, V: IntoBytes + FromBytes,
Grade: A-
[source_addr, length, data...]Large stack allocation in get_slave_messages():
let mut buffer = [0u8; 1024]; // Large for embedded stacks
Consider:
| Area | Current State | Recommendation |
|---|---|---|
| Address handling | Raw u8 | I2cAddress newtype with validation ✓ |
| Error taxonomy | ~30 Hubris-specific codes | Smaller core errors, map at boundary |
| API shape | 11+ method variants | Transaction builder or operations list |
| State management | Runtime checks | Type-state for slave mode |
| Generic bounds | Complex trait bounds | Prefer concrete types where possible |
| Client trait | Monolithic | Split I2cClient + I2cClientBlocking ✓ |
| Bus abstraction | Bundled in device | Separate BusIndex type ✓ |
I2cAddress - validated address newtypeI2cError / ResponseCode - error hierarchyBusIndex - bus identificationI2cClient trait - client API contractpw::Status