Transaction Batching
When talking to I2C or SPI devices, a single logical operation often requires multiple bus transactions — for example, writing a register address and then reading back its value. Without batching, each of these operations is a separate USB round-trip:
Host ──write──▸ USB ──▸ Firmware ──▸ I²C bus (~1 ms)
Host ◂──ack──── USB ◂── Firmware ◂── I²C bus (~1 ms)
Host ──read───▸ USB ──▸ Firmware ──▸ I²C bus (~1 ms)
Host ◂──data─── USB ◂── Firmware ◂── I²C bus (~1 ms)
Total: ~4 ms
Transaction batching packs all operations into a single USB transfer. The firmware executes them back-to-back on the bus and returns all results at once:
Host ──[write, read]──▸ USB ──▸ Firmware ──▸ I²C bus (~1 ms)
Host ◂──[data]──────── USB ◂── Firmware ◂── I²C bus (~1 ms)
Total: ~2 ms
For transactions with many operations, this is a 10–50× speedup — USB latency dominates, not bus time.
Using Batched Transactions from the CLI
The gallo CLI exposes batch operations directly. Each --op flag
specifies one bus operation.
I2C Batch
Write a register address, then read back 2 bytes:
$ gallo i2c batch -a 0x48 --op write:0x00 --op read:2
Read data (2 bytes):
0000: 19 80 ..
Write 3 bytes to an EEPROM at address 0x50, then read them back:
$ gallo i2c batch -a 0x50 --op write:0x00,0x10,0xab,0xcd,0xef --op write:0x00,0x10 --op read:3
Read data (3 bytes):
0000: ab cd ef ...
The operations execute as a single I2C transaction — the bus is not released between them (no STOP condition until the batch completes).
Available I2C operations
| Operation | Syntax | Description |
|---|---|---|
| Read | read:N | Read N bytes from the device |
| Write | write:B1,B2,... | Write the given bytes (hex 0x.. or decimal) |
SPI Batch
Read a JEDEC ID from a SPI flash (command 0x9F, 3-byte response):
$ gallo spi batch --cs 0 --op write:0x9f --op read:3
Read data (3 bytes):
0000: ef 40 18 .@.
Full-duplex transfer followed by a delay:
$ gallo spi batch --cs 1 --op transfer:0x01,0x02,0x03 --op delay:1000 --op read:4
Read data (7 bytes):
0000: ff ff ff 00 00 00 00 .......
The --cs flag specifies which GPIO pin (0–3) is used as chip-select.
The firmware asserts CS low before the first operation and deasserts it
after the last — all operations run atomically under chip-select.
Available SPI operations
| Operation | Syntax | Description |
|---|---|---|
| Read | read:N | Clock in N bytes (MISO only) |
| Write | write:B1,B2,... | Clock out the given bytes (MOSI only) |
| Transfer | transfer:B1,B2,... | Full-duplex: send on MOSI, receive same count on MISO |
| DelayNs | delay:NS | Delay for NS nanoseconds (best-effort, firmware resolution) |
Using the Lib Crate Directly
If you need batch transactions from Rust code, use the
i2c_batch and spi_batch methods on the PicoDeGallo client. These
accept typed operation slices (&[I2cBatchOp] / &[SpiBatchOp])
directly — no manual encoding needed.
I2C batch example
use pico_de_gallo_lib::{PicoDeGallo, I2cBatchOp};
#[tokio::main]
async fn main() -> Result<(), Box<dyn std::error::Error>> {
let pg = PicoDeGallo::new();
// Build a "write register pointer, then read 2 bytes" transaction
let ops = [
I2cBatchOp::Write { data: &[0x00] }, // pointer register
I2cBatchOp::Read { len: 2 }, // read temperature
];
let result = pg.i2c_batch(0x48, &ops).await?;
let temp_raw = u16::from_be_bytes([result[0], result[1]]);
let celsius = (temp_raw >> 4) as f32 * 0.0625;
println!("Temperature: {celsius:.2} °C");
Ok(())
}
SPI batch example
use pico_de_gallo_lib::{PicoDeGallo, SpiBatchOp};
#[tokio::main]
async fn main() -> Result<(), Box<dyn std::error::Error>> {
let pg = PicoDeGallo::new();
// Read JEDEC ID: send command 0x9F, then read 3 bytes
let ops = [
SpiBatchOp::Write { data: &[0x9F] },
SpiBatchOp::Read { len: 3 },
];
let result = pg.spi_batch(0, &ops).await?;
println!(
"JEDEC ID: manufacturer=0x{:02x}, type=0x{:02x}, capacity=0x{:02x}",
result[0], result[1], result[2]
);
Ok(())
}
Transparent Batching via the HAL Crate
The most powerful aspect of transaction batching is that you don’t
need to use it explicitly. The HAL crate’s embedded-hal trait
implementations use batch endpoints automatically.
When you call I2c::transaction() or SpiDevice::transaction() from
the HAL crate, the implementation encodes all operations into a single
batch request, sends it over USB, and unpacks the results — all
transparently.
This means any existing device driver that uses the standard
embedded-hal transaction API gets the performance benefit for free:
#![allow(unused)]
fn main() {
use embedded_hal::i2c::I2c;
use embedded_hal::i2c::Operation;
use pico_de_gallo_hal::Hal;
fn read_tmp102(hal: &Hal) -> Result<f32, Box<dyn std::error::Error>> {
let mut i2c = hal.i2c();
let mut buf = [0u8; 2];
// This entire transaction is ONE USB round-trip
i2c.transaction(
0x48,
&mut [
Operation::Write(&[0x00]), // set pointer to temperature register
Operation::Read(&mut buf), // read 2-byte temperature
],
)?;
let raw = u16::from_be_bytes(buf);
Ok((raw >> 4) as f32 * 0.0625)
}
}
Similarly, SpiDevice::transaction() batches all SPI operations and
manages chip-select automatically:
#![allow(unused)]
fn main() {
use embedded_hal::spi::SpiDevice;
use embedded_hal::spi::Operation;
use pico_de_gallo_hal::Hal;
fn read_spi_jedec(hal: &Hal) -> Result<[u8; 3], Box<dyn std::error::Error>> {
let mut spi = hal.spi_device(0); // CS on GPIO 0
let mut id = [0u8; 3];
// One USB round-trip: CS asserted, command sent, ID read, CS deasserted
spi.transaction(&mut [
Operation::Write(&[0x9F]),
Operation::Read(&mut id),
])?;
Ok(id)
}
}
Before vs. After
Consider an EEPROM page write that requires a write-enable command followed by the actual page write, then a status poll:
| Approach | USB Round-Trips | Approx. Latency |
|---|---|---|
| Without batching (3 × write/read) | 6 | ~6 ms |
| With batching (1 × batch) | 2 | ~2 ms |
The improvement grows with the number of operations in each transaction.
Wire Format Details
For those interested in the protocol internals, batch operations use
postcard serialization. Each I2cBatchOp or SpiBatchOp is serialized
individually using postcard::to_slice, and the resulting bytes are
concatenated into the ops field. The firmware decodes them one at a
time using postcard::take_from_bytes.
I2C operation encoding (postcard)
| Variant | Encoding |
|---|---|
Read { len } | varint 0 (variant index) + varint len |
Write { data } | varint 1 + varint data length + raw bytes |
SPI operation encoding (postcard)
| Variant | Encoding |
|---|---|
Read { len } | varint 0 + varint len |
Write { data } | varint 1 + varint data length + raw bytes |
Transfer { data } | varint 2 + varint data length + raw bytes |
DelayNs { ns } | varint 3 + varint ns |
The count field in each batch request struct tells the firmware how
many operations to expect, providing an additional safety check during
decoding.
The response for both I2C and SPI is simply the concatenated read (and transfer) data. The host already knows the expected lengths from the request, so no framing is needed in the response.
Limits
| Parameter | Value |
|---|---|
| Maximum operations per batch | 64 (MAX_BATCH_OPS) |
| Maximum total payload | 4096 bytes (MAX_TRANSFER_SIZE) |
| Maximum response data | 4096 bytes |
If a batch exceeds these limits, the firmware returns an error indicating which limit was violated and, for operation-level failures, which operation failed (zero-indexed).