Keyboard shortcuts

Press or to navigate between chapters

Press S or / to search in the book

Press ? to show this help

Press Esc to hide this help

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

OperationSyntaxDescription
Readread:NRead N bytes from the device
Writewrite: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

OperationSyntaxDescription
Readread:NClock in N bytes (MISO only)
Writewrite:B1,B2,...Clock out the given bytes (MOSI only)
Transfertransfer:B1,B2,...Full-duplex: send on MOSI, receive same count on MISO
DelayNsdelay:NSDelay 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:

ApproachUSB Round-TripsApprox. 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)

VariantEncoding
Read { len }varint 0 (variant index) + varint len
Write { data }varint 1 + varint data length + raw bytes

SPI operation encoding (postcard)

VariantEncoding
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

ParameterValue
Maximum operations per batch64 (MAX_BATCH_OPS)
Maximum total payload4096 bytes (MAX_TRANSFER_SIZE)
Maximum response data4096 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).