Create i2c (and spi) generic interface
The current Datagram_Device interface, while functional for basic communication, presents several limitations when working with I²C and SPI peripherals:
- No type-safety: Drivers cannot enforce that they receive the correct bus type (I²C vs SPI), allowing runtime errors when incompatible buses are provided.
- Missing bus-specific features: Important features like I²C addressing, SPI chip select management, and bus-specific configuration options are not expressible through the generic datagram interface.
- Clients must handle bus-specific setup (like I²C addresses) outside of the driver, leading to scattered configuration and potential inconsistencies.
- Less flexibility: Cannot leverage bus-specific optimizations or handle bus-specific error conditions appropriately.
- Difficulty adapting to Datagram_device: The current interface requires
readv, which on some chips (e.g. nrf52) will not work.
I propose that we introduce dedicated I2C_Device and SPI_Device interfaces that provide type safety and expose bus-specific functionality while maintaining compatibility with existing code where appropriate.
Having specific types will additionally allow us to expose bus-specific errors (e.g. Nack) while providing compile-time validation of the provided bus.
// Old approach
pub const TempSensor = struct {
device: mdf.base.Datagram_Device,
pub fn init(device: mdf.base.Datagram_Device) TempSensor {
// Address handling is external
return TempSensor{ .device = device };
}
};
// New approach
pub const TempSensor = struct {
i2c: I2C_Device,
pub fn init(i2c: I2C_Device, address: I2C_Address) !TempSensor {
try i2c.set_address(address);
if (!try i2c.probe_device()) return error.SensorNotFound;
return TempSensor{ .i2c = i2c };
}
pub fn read_temperature(self: *TempSensor) !f32 {
var buffer: [2]u8 = undefined;
try self.i2c.writev_then_readv(&.{&.{0x00}}, .{&buffer}); // Register read
// ...
}
};
Implementation could be done rather easily in four steps:
- Introduce new interface
- Add implementations for each port's driver.
- Update drivers to use the bus-specific interface
- Update existing examples to use new driver interface.
Steps 3 & 4 would need to be done in single PR.
I was just looking into I2C and isn't a I2C interface provided in the experimental folder? They are only implemented for the stm32 devices it looks like for now (I am looking at adding it for the gd32vf103)
That might inform what we make this look like, but I am pretty sure it doesn't even work anymore, and to be honest, I didn't know or forgot it even existed.
Sounds good to me! I implemented an avr programmer and I couldn't port it to the driver framework because it required a transcieve function for spi.
I will put my suggestion made on discord here for easier access.
Bus first approach
pub const ConnectError = error{
Busy,
BusFull,
InvalidDevice,
};
pub const DataError = error{
Unsupported,
BufferOverrun,
NotConnected,
Busy,
TxFail,
RxFail,
Timeout,
};
pub const BusOperations = enum {
Write_Only,
Read_Only,
Full_Duplex, //read/send at the same time
Half_Duplex, //send all then read all
};
pub const BusCapabilities = packed struct {
//Data features
write_only: bool,
read_only: bool,
full_duplex: bool,
burst: bool, //if true, Half-Duplex Operations will send all then read all, if false, the bus will alternate between send/read on each operation
//misc configs
auto_management: bool,
multi_master: bool, //if true, the device of this I/O can safe be connected into a multi_master bus
};
pub const ConnectMode = enum {
exclusive, //the device want to get full control over the bus
shered, //the device can be connect with other decives
force, //the device want do connect to the bus without checking for error
};
pub const IOInterface = struct {
ctx: ?*anyopaque = null,
device_id: ?u32 = null, //buses sharing with the feature: auto_management enabled may need an id to work,
capabilities: *const fn (ctx: ?*anyopaque) BusCapabilities,
enable: ?*const fn (id: ?u32, ctx: ?*anyopaque) void,
disable: ?*const fn (id: ?u32, ctx: ?*anyopaque) void,
transciver: *const fn (id: ?u32, ctx: ?*anyopaque, mode: BusOperations, output: ?[]const []const u8, input: ?[]const []u8) DataError!void,
//helpers
pub fn writev(self: *const IOInterface, data: []const []const u8) DataError!void {
const id = self.decive_id;
const ctx = self.ctx;
return self.transciver(id, ctx, .Write_Only, data, null);
}
pub fn readv(self: *const IOInterface, data: []const []u8) DataError!void {
const id = self.decive_id;
const ctx = self.ctx;
return self.transciver(id, ctx, .Read_Only, null, data);
}
//this example is master_only, so no readv_then_writev yet
pub fn writev_then_readv(self: IOInterface, out: []const []const u8, in: []const []u8) DataError!void {
const id = self.decive_id;
const ctx = self.ctx;
return self.transciver(id, ctx, .Half_Duplex, out, in);
}
};
I’ll explain each detail:
Datagram and Stream are now: BusCapabilities and BusOperations
BusCapabilities
- Currently, the standard is "call and discover" to check if the device supports a given feature.
-
connectanddisconnectare used internally. - Datagram and Stream have similar interfaces, with small behavioral differences.
Now, the implementation must inform which features are supported, and how Bus operations are performed.
- Data features: These features define what was previously the behavior of datagram and stream.
-
auto_management:
connectanddisconnectno longer belong to the driver layer.
The implementation must indicate whether it managesconnectanddisconnectinternally (e.g., I2C start and stop inside a datagram) or if the user must handle it manually (e.g., an SPI that uses CS on a regular GPIO).
Note: Currently, all I2C HALs perform start and stop within write and read functions. Adding separate methods might be a good idea.
-
multi-master:
The implementation must indicate if it can handle multi-master situations (e.g., STM32 can detect other masters via NSS, allowing it to return a"busy"error instead of attempting to write to the bus and corrupting data).
BusOperations
The write behavior of datagram and stream is now part of Bus operations.
This is very useful when dealing with multiple protocols in the same driver, such as:
- An SPI display with an I2C touch controller
- SPI or USART with modem (AT) operations
ConnectMode
If keeping connect and disconnect in the driver layer, it would be a good idea to define rules for this connection.
IOInterface
This would be the unified IO interface that drivers will use.
-
device_id:
Optional data that stores some type of identifier about the IO.
It is entirely defined by the implementation.
In this example, it’s au32that can represent:- An I2C address
- A physical pin for SPI CS
- An index in an array
- A DMA device number
The driver just wants to send and receive data.
-
capabilities():
The driver must be able to check which features are available on this IO line. -
enable() and disable():
The driver must be able to indicate the state of the connected hardware (e.g., sleep functions).
The device can use this for:- Power management
- Disabling DMA
- Clearing internal buffers
-
transceiver():
The driver must be able to select the desired operation mode (to behave as a datagram or a stream) for an IO operation.
Notes
This is just an improvement over the current datagram and stream device implementation —
the core functionality remains the same
This can also be used as a middle ground between protocol-specific implementations and hardware that does not require anything specific (e.g., TFT displays such as ST77xx or ILIxx — regardless of the communication mode [3-SPI, SPI, 8-bit parallel, 16-bit parallel], all data and config order remain the same).
Another point is to allow for user-customized devices while maintaining full driver compatibility.
One approach was merged in #645, though the design we select has still not been finalized
Suggestions
1. Add an optional ?Duration argument to all blocking Read/Write methods across every interface:
Even though this was originally intended for use with time.Deadline, a Duration itself is basically just a microsecond label. Nearly every blocking I/O implementation already needs some form of timeout, so I don’t see why we shouldn’t make this explicit.
2. Generic protocol bindings directly from implementations
I understand the reasoning behind the current design, but having something like i2c_device() directly from hal.I2C wouldn’t be that bad. After all, I2C_Device only needs a pointer to a concrete object—something that already exists anyway.
3. Replace *anyopaque as context in interfaces with @fieldParentPtr
The main difference here is stylistic. Instead of having something like FooInterface.datagram_device();, we could expose it as FooInterface.datagram;. Drivers would then receive a pointer to a datagram interface rather than a copy of a datagram.
The same idea could be applied to suggestion 2: the hal.I2C implementation itself could already include a .interface field.
4. Make <Protocol>_Datagram_Device generic
Using I²C as an example, we could have a type I2C_Datagram that takes *I2C_Device + address. This would allow deriving a datagram device directly from an existing i2c_device.
Yes, it adds a bit of overhead, but it provides a much clearer layer separation.