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

I²C

Pico de Gallo provides a single I²C bus on the RP2350’s hardware I²C1 controller. SDA is on GPIO 2 and SCL on GPIO 3. The v1.1 PCB includes on-board 4.7 kΩ pull-ups; on v1.0 you must supply your own.

Operations

OperationDescription
ReadRead N bytes from a device at the given address
WriteWrite bytes to a device at the given address
Write-ReadWrite then read on the same target (repeated start, no STOP between)
ScanProbe every address on the bus
BatchOne I²C transaction; repeated START on direction change; final STOP only
Set ConfigChange the bus clock frequency at runtime
Get ConfigQuery the current bus configuration

Bus Frequencies

VariantValueStandard name
Standard100 kHzI²C Standard mode
Fast400 kHzI²C Fast mode
FastPlus1 MHzI²C Fast-mode Plus

The firmware defaults to Standard mode.

CLI

$ gallo i2c --help
I2C access methods

Usage: gallo i2c <COMMAND>

Commands:
  scan        Scan I2C bus for existing devices
  read        Read bytes through the I2C bus from device at given address
  write       Write bytes through I2C bus to device at given address
  write-read  Write bytes follwed by read bytes
  set-config  Set I2C bus parameters
  get-config  Query the current I2C bus configuration
  batch       Execute multiple I2C operations in a single USB transfer
  help        Print this message or the help of the given subcommand(s)

Options:
  -h, --help  Print help

Scanning

Warning

The RP235x I²C controller doesn’t expose a pure address-probe primitive, so gallo i2c scan does a 1-byte read at each address. Devices that ACK a read are reported as present. A handful of peripherals may end up in an unexpected state after being probed this way — usually a power cycle clears it.

$ gallo i2c scan
╭────┬────┬────┬────┬────┬────┬────┬────┬────┬────┬────┬────┬────┬────┬────┬────┬────╮
│    │  0 │  1 │  2 │  3 │  4 │  5 │  6 │  7 │  8 │  9 │  a │  b │  c │  d │  e │  f │
├────┼────┼────┼────┼────┼────┼────┼────┼────┼────┼────┼────┼────┼────┼────┼────┼────┤
│ 0  │ RR │ RR │ RR │ RR │ RR │ RR │ RR │ RR │ -- │ -- │ -- │ -- │ -- │ -- │ -- │ -- │
│ 1  │ -- │ -- │ -- │ -- │ -- │ -- │ -- │ -- │ -- │ -- │ -- │ -- │ -- │ -- │ -- │ -- │
│ 2  │ -- │ -- │ -- │ -- │ -- │ -- │ -- │ -- │ -- │ -- │ -- │ -- │ -- │ -- │ -- │ -- │
│ 3  │ -- │ -- │ -- │ -- │ -- │ -- │ -- │ -- │ -- │ -- │ -- │ -- │ -- │ -- │ -- │ -- │
│ 4  │ -- │ -- │ -- │ -- │ -- │ -- │ -- │ -- │ 48 │ -- │ -- │ -- │ -- │ -- │ -- │ -- │
│ 5  │ -- │ -- │ -- │ -- │ -- │ -- │ -- │ -- │ -- │ -- │ -- │ -- │ -- │ -- │ -- │ -- │
│ 6  │ -- │ -- │ -- │ -- │ -- │ -- │ -- │ -- │ 68 │ -- │ -- │ -- │ -- │ -- │ -- │ -- │
│ 7  │ -- │ -- │ -- │ -- │ -- │ -- │ -- │ -- │ RR │ RR │ RR │ RR │ RR │ RR │ RR │ RR │
╰────┴────┴────┴────┴────┴────┴────┴────┴────┴────┴────┴────┴────┴────┴────┴────┴────╯

RR marks reserved I²C addresses. Pass -r (--include-reserved) to probe them anyway.

Read / Write / Write-Read

$ gallo i2c read --address 0x48 --count 2
6b 15

$ gallo i2c write --address 0x48 --bytes 0x01 0xe0 0xa0

$ gallo i2c write-read --address 0x48 --bytes 0x00 --count 2
6b 15

Read output supports -f hex (default), -f binary, and -f ascii.

Config

$ gallo i2c set-config --frequency fast
$ gallo i2c get-config
Frequency: Fast (400 kHz)

Batch

One USB round-trip carries one multi-operation I²C transaction. A START and address precede the first operation. Adjacent operations in the same direction run back to back without a STOP or repeated START, so adjacent writes form one gather write. A direction change emits a documented repeated START and re-addressing, and only the final operation is followed by a STOP.

This bus framing requires firmware from schema 0.7 or newer. Older firmware executes each operation as a separate transaction.

$ gallo i2c batch -a 0x48 --op write:0x00 --op read:2
Read data (2 bytes):
  0000: 19 80                                              ..

See Transaction Batching for the full mechanism.

Measured Transfer Limits

I²C uses independent directional ceilings. i2c_read and the read half of i2c_write_read may return at most MAX_RESPONSE_PAYLOAD (1014 bytes). i2c_write and the write half of i2c_write_read may send at most MAX_TRANSFER_SIZE (4096 bytes). Every host surface refuses an over-ceiling argument with BufferTooLong before transmitting.

The response ceiling is derived from the host transport, not fitted to the measurement: 1024 bytes of inbound USB transfer − 7 bytes of response header − 1 postcard Result discriminant − 2 bytes of varint length prefix = 1014.

Before issue #158 added those local guards, the schema-0.7 measurement behind issue #146 found that an I²C read of 1014 bytes after a one-byte write returned exactly the requested length. The 1015-byte probe failed in about 99 ms; larger tested lengths returned the same host-side response decode error, Postcard(DeserializeUnexpectedEnd), with increasing latency, reaching 391 ms at 4096. No truncation was found at checked lengths of 1, 64, 256, 512, 1000, 1013 and 1014 bytes.

No failing write request length was observed through 4096 bytes. Every probe crossed USB intact, was decoded, initiated a bus transaction and returned the expected address NACK. The target address was unpopulated, so no payload byte was clocked: this verifies request framing at 4096, not successful bus-level clocking of a 4096-byte payload. The probe could go no higher because 4096 is MAX_TRANSFER_SIZE.

No hang was found at any tested I²C length, but that does not prove that no I²C hang window exists. The combined write/read frontier remains unmeasured beyond a one-byte write because the available TMP102 rejects longer writes. Issue #158 superseded the earlier inference about independent bounds by deriving and enforcing them explicitly according to direction.

The Zephyr driver and every direct host surface now enforce the same values locally. Zephyr returns -EMSGSIZE; the other surfaces report their BufferTooLong mapping. See troubleshooting.

Rust Library

All PicoDeGallo methods are async. PicoDeGallo::new() is not async.

use pico_de_gallo_lib::{I2cBatchOp, I2cFrequency, PicoDeGallo};

#[tokio::main]
async fn main() -> Result<(), Box<dyn std::error::Error>> {
    let pg = PicoDeGallo::new();

    pg.i2c_set_config(I2cFrequency::Fast).await?;

    // Plain write-read
    let data = pg.i2c_write_read(0x48, &[0x00], 2).await?;
    let raw = u16::from_be_bytes([data[0], data[1]]);
    println!("raw = 0x{raw:04x}");

    // Same bus framing as i2c_write_read above: write, repeated START, read, STOP.
    let ops = [
        I2cBatchOp::Write { data: &[0x00] },
        I2cBatchOp::Read { len: 2 },
    ];
    let _ = pg.i2c_batch(0x48, &ops).await?;
    Ok(())
}

HAL

The HAL exposes the bus as an [embedded_hal::i2c::I2c] / [embedded_hal_async::i2c::I2c] implementor — so any driver written against those traits Just Works:

#![allow(unused)]
fn main() {
use embedded_hal::i2c::I2c;
use pico_de_gallo_hal::Hal;

fn read_tmp102(hal: &Hal) {
    let mut i2c = hal.i2c();
    let mut buf = [0u8; 2];
    i2c.write_read(0x48, &[0x00], &mut buf).unwrap();
    let raw = u16::from_be_bytes(buf);
    let celsius = (raw >> 4) as f32 * 0.0625;
    println!("Temperature: {celsius:.2} °C");
}
}

I2c::transaction() sends all operations in one USB round-trip and, with schema 0.7 or newer firmware, executes them as one I²C transaction. Adjacent same-direction operations run without an intervening STOP; a direction change emits a documented repeated START. See Transaction Batching.

C (FFI)

#include "pico_de_gallo.h"
#include <stdio.h>

void read_tmp102(PicoDeGallo *gallo) {
    uint8_t tx[] = {0x00};
    uint8_t rx[2];
    Status s = gallo_i2c_write_read(gallo, 0x48, tx, 1, rx, 2);
    if (s != Ok) { fprintf(stderr, "write-read failed: %d\n", s); return; }
    uint16_t raw = ((uint16_t)rx[0] << 8) | rx[1];
    printf("raw = 0x%04x\n", raw);
}

I²C frequency is passed as uint8_t: 0 = Standard, 1 = Fast, 2 = FastPlus. See crates/ffi.md.

Python

from pyco_de_gallo import PycoDeGallo, I2cFrequency

pg = PycoDeGallo()
pg.i2c_set_config(I2cFrequency.Fast)

data = pg.i2c_write_read(0x48, [0x00], 2)
raw = (data[0] << 8) | data[1]
print(f"raw = 0x{raw:04x}")

Error Handling

I²C operations return PicoDeGalloError<I2cError> on the Rust side; FFI returns negative Status values:

VariantMeaning
BusUnexpected condition on the I²C bus
NoAcknowledgeTarget did not acknowledge
ArbitrationLossLost arbitration to another master
OverrunData overrun on read
BufferTooLongArgument exceeds its directional payload ceiling
AddressOutOfRangeAddress outside the 7-bit range
OtherUnspecified firmware error
ZeroLengthWriteWrite requested with an empty payload

The full status-code mapping for FFI lives in appendix/status-codes.md. ZeroLengthWrite maps to InvalidArgument (-5).

Zero-Length Writes Are Not Supported

A write with an empty payload — the address-only START + ADDR + STOP probe that some I²C stacks use for bus scanning — is rejected by the firmware with ZeroLengthWrite. This applies to i2c/write and to any Write operation inside an i2c/batch; the batch is refused as a whole during validation, so no earlier operation in it reaches the bus.

Every host surface also refuses it locally, before the request is transmitted, so the call fails immediately instead of spending a USB round-trip to be told no. The Zephyr module is not a host surface — it is an FFI consumer — but it refuses the same shape locally, and is listed here alongside them:

SurfaceRefusal
pico-de-gallo-libPicoDeGalloError::Endpoint(I2cError::ZeroLengthWrite)
pico-de-gallo-halI2cHalError::I2c(ZeroLengthWrite), whose ErrorKind is Other
C FFIStatus::InvalidArgument (-5); for a batch, *out_failed_op names the operation
pyco-de-galloRuntimeError carrying the same message
gallo-mcpAn invalid-argument error naming the offending operation
gallo CLIUnreachable: the byte arguments require at least one value
Zephyr module-ENOTSUP; set CONFIG_I2C_PICO_DE_GALLO_PROBE_WITH_READ to substitute a 1-byte read instead. See zephyr/README.md

Except for the Zephyr opt-in, which substitutes a read rather than refusing, the local refusal returns the identical error the firmware would have returned, so callers need not distinguish the two.

Note that i2c/write-read is not affected: an empty write phase there is legal, because that transfer does not terminate with a STOP. Probing with i2c_write_read(addr, &[], n) works.

The restriction is a hardware limitation, not a firmware policy choice. The RP2040/RP2350 DW_apb_i2c block drives the address phase only as a side effect of pushing data into IC_DATA_CMD, so there is no way to emit an address without at least one payload byte. See rp-rs/rp-hal#678 and embassy-rs/embassy#4474.

To probe for a device, use a 1-byte read instead:

gallo i2c read --address 0x48 --count 1   # NoAcknowledge => absent
gallo i2c scan                            # or scan the whole bus

i2c/scan already probes this way. Note that a read probe is not semantically identical to a write probe: a write-only device may acknowledge its write address while refusing a read address.