Skip to content
Basic

ww_i2c

Part of the IO trait family. ww_i2c is a global WireWeaver trait family for an I2C bus reachable through a device: a USB adapter, a dev board, or an instrument. A host can configure the bus, run write/read/transaction calls against a slave address, scan the bus, see and recover stuck lines, make the device a slave answering another master (slave mode), let the device poll a few registers on its own (I2cWatch), or watch every transaction on the bus without taking part in it (I2cMonitor). It is no_std by default and depends only on wire_weaver and ww_si (for the Second and Volt reference types).

Add it to a device API crate with:

[dependencies]
ww_i2c = "0.1"

Traits

Trait What it is for Who implements it
I2c Configure the bus, write, read, write-read, transactions, scan, bus lines and recovery; optionally a slave. Every I2C master
I2cWatch Autonomous reads: the device repeats configured reads and reports only changes. Adapters that offload polling
I2cMonitor Every transaction on the bus as a stream, without taking part in it. Sniffers, debug adapters

I2cMonitor is its own trait because it is its own function: a sniffer may have no master at all, and most masters can't sniff. A host finds out what is there through introspection, and what I2c supports through I2c::i2c_capabilities.

#[ww_api_root]
trait MyAdapter {
    ww_impl!(i2c: ww_i2c::I2c);
    ww_impl!(i2c_watch: ww_i2c::I2cWatch);
    ww_impl!(i2c_monitor: ww_i2c::I2cMonitor);
}

I2c

The most common call of a register-based sensor driver is a write-read: i2c_read with I2cReadKind::RepeatedStart (register address out, data in, one transaction). Several calls in one round trip are a multi-call of the client, not a method here - use i2c_transaction when the device itself must do several operations without releasing the bus in between (repeated starts).

#[ww_trait]
pub trait I2c {
    fn i2c_capabilities() -> I2cCapabilities<'i>;
    fn i2c_configure(config: I2cConfig) -> Result<I2cConfig, I2cError<'i>>;
    fn i2c_write(addr: I2cAddress, data: MaxLen<RefVec<'i, u8>, MAX_WRITE_LEN>, delay_after: Option<Second>)
        -> Result<I2cTransactionTimeRange, I2cError<'i>>;
    fn i2c_read(addr: I2cAddress, kind: I2cReadKind<'i>, len: u16, delay_after: Option<Second>)
        -> Result<I2cReadEnvelope<'i>, I2cError<'i>>;
    fn i2c_scan() -> Result<MaxLen<RefVec<'i, u8>, MAX_SCAN>, I2cError<'i>>;
    fn i2c_transaction(addr: I2cAddress, ops: MaxLen<RefVec<'i, I2cOp<'i>>, MAX_OPS>)
        -> Result<I2cTransactionResult<'i>, I2cError<'i>>;
    fn i2c_lines_state() -> I2cLinesState;
    fn i2c_wiggle_clock(count: u8) -> Result<I2cLinesState, I2cError<'i>>;
    fn i2c_set_pullups(enabled: bool) -> Result<(), I2cError<'i>>;
    fn i2c_pullups() -> bool;
}
  • i2c_capabilities - static description of what the adapter supports: modes, frequency range, IO levels, buffer sizes, 10-bit addresses, pull-ups, bus recovery; see I2cCapabilities under Types.
  • i2c_configure - sets the bus mode (for a master its SCL frequency and clock stretch timeout) and, on adapters with level translators, the bus voltage, in one I2cConfig (None leaves the level or the timeout as it is); answers with what was actually set: the SCL frequency (the highest the hardware can do that is not above the request, computed from its real timing), the stretch timeout and the IO level in effect.
  • i2c_write - start, address, data, stop; delay_after makes the next transfer to the same address wait (an EEPROM page write needs ~5 ms) while transfers to other addresses go ahead meanwhile. Empty data is an address-only write (probing, SMBus Quick Command) on adapters with I2cCapabilities::zero_length_write, Common(Unsupported) on the others.
  • i2c_read - plain read, or a register read (write a few bytes, repeated start, read) through I2cReadKind.
  • i2c_scan - 7-bit addresses that acknowledged a one-byte read, ascending, skipping the reserved ranges (0x00..=0x07, 0x78..=0x7F).
  • i2c_transaction - several I2cOp writes/reads to one address in one transaction, see Transactions; the bytes of all reads come back concatenated, in order.
  • i2c_lines_state - levels of SCL and SDA now; both high on an idle bus, a line held low means a slave stuck mid-byte, missing/unpowered pull-ups, or a short.
  • i2c_wiggle_clock - bus recovery: clocks SCL up to count times (9 is enough for any stuck byte) at the configured SCL frequency but at most 100 kHz, stopping once SDA is released, then sends a stop; returns the line levels afterwards. It works while the bus is disabled or a master between transfers; Common(NoPower) when the bus has no level to drive (level translators without an IO level, a bus off while disabled). A device never frees the bus on its own: a transfer that finds a line held low fails with BusStuck, and the host decides to recover.
  • i2c_set_pullups / i2c_pullups - the adapter's own pull-up resistors on SCL and SDA, on adapters that have switchable ones (see I2cCapabilities::pullups).

Transactions

Every transfer starts with a start condition and ends with a stop. Inside one call, every operation starts with the address and its direction bit, after a repeated start for all but the first, as Linux's I2C_RDWR does:

Call On the bus
i2c_write(a, [r, d0, d1]) S a+W r d0 d1 P
i2c_read(a, RepeatedStart { write: [r] }, 2) S a+W r Sr a+R x0 x1 P
i2c_transaction(a, [Write([r]), Read(2)]) S a+W r Sr a+R x0 x1 P (the same)
i2c_transaction(a, [Write([r]), Write([d0, d1])]) S a+W r Sr a+W d0 d1 P

The last line is not one longer write: embedded_hal::i2c::I2c::transaction merges adjacent operations of the same direction without a repeated start, i2c_transaction doesn't. An EEPROM page write (memory address, then data) is one Write with both, as on a real 24Cxx the repeated start would make the data a new memory address. A host adapter implementing embedded-hal over ww_i2c (ww_i2c_hl) merges before the call: adjacent writes concatenated into one Write, adjacent reads into one Read of the summed length, and splits the read bytes again.

Nack errors say where the transfer stopped: op is the operation (0 for i2c_write and a plain read, 0 or 1 for the write or read part of a RepeatedStart read, the index into ops for i2c_transaction), at the byte on the bus within it, counted as I2cRecord::nack_at does: Some(0) the address, Some(n) the n-th data byte, so n - 1 bytes went through; None when the controller can't tell. Only writes have data NACKs: in a read the master itself doesn't acknowledge the last byte, which is no error.

I2cWatch

Autonomous reads: the device repeats configured reads on its own and reports only what the host asked for (every result, a change under a mask, or a mask/value match starting or stopping), so the host doesn't have to poll over the link. A device implementing it also implements I2c (it needs a master). Host transactions have priority: the device runs a watch read only when no host transaction is waiting, so host calls are spliced in between autonomous ones and see no more delay than one watch read.

#[ww_trait]
pub trait I2cWatch {
    #[counted]
    stream!(changes: I2cWatchEvent<'i>);
    fn watch_capabilities() -> I2cWatchCapabilities;
    fn watch_add(watch: I2cWatchConfig<'i>) -> Result<u32, I2cError<'i>>;
    fn watch_modify(slot: u32, watch: I2cWatchConfig<'i>) -> Result<(), I2cError<'i>>;
    fn watch_remove(slot: u32) -> Result<(), I2cError<'i>>;
    fn watch_list() -> MaxLen<RefVec<'i, u32>, MAX_WATCHES>;
}
  • watch_add / watch_modify / watch_remove / watch_list - manage watch slots (at most I2cWatchCapabilities::max_watches, see Types); modifying a watch forgets its previous result, so the next read is always reported.
  • changes - results, in the order the watches were read, as I2cWatchEvent. A counted stream (unit: result): the host opens it by calling it, the event head carries when each read started and how many results the device dropped (stream delivery).

I2cWatchReport (part of I2cWatchConfig) decides when a result is worth a report:

  • Always - every read.
  • Changed { mask } - when data & mask differs from the previous reported read's (mask as long as the read; all-ones to see any change).
  • Matches { bytes } - when data[i] & bytes[i].mask == bytes[i].value for every byte starts or stops being true (an alert bit set, a status reaching a state) - a transition, not every read while it holds. One I2cMatchByte { mask, value } per byte read, two bytes each on the wire; a mask of 0x00 ignores that byte.

An error is reported once, when the watch starts to fail, and the next successful read is reported unconditionally whatever the rule - and that recovery read becomes the new baseline, so a later unchanged read goes quiet again instead of being reported a second time. tests/io_i2c exercises all three rules plus this error/recovery sequence end to end against a mock server.

I2cMonitor

Watches the bus without taking part: every transaction becomes one I2cRecord on the records stream. The same function as I2cMode::MonitorOnly (or the monitor flag of I2cMode::Master, to watch the adapter's own transfers too), for devices that only sniff and implement no I2c.

#[ww_trait]
pub trait I2cMonitor {
    #[stream(I2cRecord<'i>, counted)]
    fn records(io_level: Option<Volt>) -> Result<(), I2cError<'i>>;
}

records is a counted stream function: opening it starts monitoring (at io_level, as in I2cConfig; the adapter's pull-ups stay as they are, the watched bus has its own), closing it stops. Its unit is a transaction: the event head carries the time of each start condition and exactly how many records the device dropped and where (stream delivery).

Slave mode

A device with the slave group of I2c (optional, left out with omit = ["i2c.slave"]) stands in for a chip on another master's bus, at an address of its own:

#[ww_trait]
pub trait I2c {
    // ...
    #[optional(slave)]
    fn slave_capabilities() -> I2cSlaveCapabilities;
    #[optional(slave)]
    #[stream(I2cSlaveEvent<'i>, counted)]
    fn slave_events(config: I2cSlaveConfig) -> Result<(), I2cError<'i>>;
    #[optional(slave)]
    fn slave_map_write(offset: u32, data: MaxLen<RefVec<'i, u8>, MAX_WRITE_LEN>) -> Result<(), I2cError<'i>>;
    #[optional(slave)]
    fn slave_map_read(offset: u32, len: u16) -> Result<MaxLen<RefVec<'i, u8>, MAX_READ_LEN>, I2cError<'i>>;
    #[optional(slave)]
    #[credit]
    sink!(slave_answers: MaxLen<RefVec<'i, u8>, MAX_READ_LEN>);
    #[optional(slave)]
    fn slave_clear_answers() -> u32;
}

Opening slave_events with an I2cSlaveConfig { addr, io_level, answers } starts it, closing the stream stops it (the address is not acknowledged any more). answers picks where the bytes of the master's reads come from:

  • I2cSlaveAnswers::Registers { pointer_bytes, len, page, read_only } - a register map on the device, filled and read back by the host with slave_map_write / slave_map_read, before and while the slave runs. The first pointer_bytes bytes of each master write set the pointer (big-endian), the rest are stored from it; reads come from the pointer; both go up by one per byte and wrap at len, writes within their page as in an EEPROM. The device answers at bus speed with the host out of the loop, and a host update takes effect between two master transactions, never inside one, so the master never reads half of a multi-byte value. EEPROMs, sensors, PMICs, anything register-based.
  • I2cSlaveAnswers::Queue { empty } - answers the host sends on the slave_answers credit sink, one per master read: a shorter read leaves the rest unsent, a longer one gets the fill byte. For command-response protocols where the host computes each answer.

slave_events returns one I2cSlaveEvent::Transfer per transaction addressed to the device (what the master wrote or what the device sent, the register pointer, how many bytes were fill and how long SCL was held), and I2cSlaveEvent::ReadWaiting as soon as the master reads with no answer queued. The write just before it (a command, a register address) is the previous event, so a host answering live reads the request, computes the answer and sends it on slave_answers.

Clock stretching and a host that is too slow

I2cSlaveUnderrun (empty above) says what the device does when the master reads and no answer is there:

  • Fill(byte) - sends byte for every byte read (0xFF is what a released bus reads); the master never waits.
  • Stretch { max, fill } - holds SCL low until an answer comes, at most max per read, then sends fill for the rest. The answer that comes late goes to the next read, and I2cSlaveTransfer::filled tells the host it was late.
  • Nack - doesn't acknowledge the address while the queue is empty, writes included, as an EEPROM busy with a page write does; a master polling for an acknowledge gets one once an answer is queued.

A host answer takes a round trip over the link: a few milliseconds on USB full speed, more on a busy host (measure it on the device at hand with I2cSlaveTransfer::stretched). The I2C specification puts no limit on stretching, but SMBus devices give up after 25 ms (tTIMEOUT), many masters have timeouts of their own, and some can't be stretched at all (the Raspberry Pi's BCM2835 controller corrupts the transfer). Use Stretch only with a master that takes it, with max below its timeout, and the register map whenever an answer can be prepared ahead. I2cSlaveCapabilities tells the map size, the queue depth, the longest stretch (None: none on demand), whether Nack works, 10-bit addresses and the fastest SCL the slave keeps up with.

Addresses

I2cAddress is 7-bit (0x50 for a 24C02 EEPROM, not 0xA0) or 10-bit, as in the I2C specification and embedded-hal (SevenBitAddress / TenBitAddress): I2cAddress::seven(0x50), I2cAddress::ten(0x350), both returning None above the respective range. It is bit-packed (SW-48, sub-byte fields): a 7-bit address takes one byte on the wire, exactly as a plain u8 would, and a 10-bit one 11 bits (2 bytes) - ww_i2c's own test suite checks both sizes against a real to_ww_bytes() call. An adapter without 10-bit support answers Common(Unsupported) (see I2cCapabilities::ten_bit_addresses). i2c_scan still only returns 7-bit addresses (a 10-bit scan is rarely useful and would need 1024 single-byte reads).

Sizes

Every byte buffer is bounded (MAX_WRITE_LEN, MAX_READ_LEN, MAX_WRITE_READ_LEN, MAX_OPS, MAX_SCAN, MAX_RECORD_LEN, MAX_WATCHES, MAX_WATCH_LEN, MAX_NAME_LEN, MAX_IO_LEVELS, MAX_SPEEDS), so a device knows the largest request it has to take and the largest reply it has to build at compile time. A device may support less: its limits are in I2cCapabilities / I2cWatchCapabilities. Raising a bound later is a compatible change, lowering it is not (see evolution rules).

Time

Every transfer reports when it started and ended (I2cTransactionTimeRange), and every monitor record and watch result when it happened (the time in the event head of its counted stream), as ww_si::Seconds of device time: time since the device's clock started, in the prefix and number type the device picks (usually a u32 of microseconds). A fixed-width value wraps (a u32 of µs every 71.6 minutes) and the host unwraps it. All timestamps of one device come from the same clock, so I2C transfers line up with the device's other data.

Types

  • I2cConfig - mode: I2cMode, io_level: Option<Volt>; the argument and the answer of i2c_configure.
  • I2cMode - Disabled (pins released), Master { speed: Hertz, monitor, stretch_timeout: Option<Second> }, MonitorOnly. The stretch timeout is the longest a slave may hold SCL low at once before the transfer is aborted with ClockStretch; a device always enforces one (a stuck SCL never hangs it), so its answer is always Some. Slave mode is slave_events, not a mode.
  • I2cSlaveConfig - addr: I2cAddress, io_level: Option<Volt>, answers: I2cSlaveAnswers.
  • I2cSlaveAnswers - Registers { pointer_bytes: u8, len: u32, page: Option<u16>, read_only: bool } or Queue { empty: I2cSlaveUnderrun }, see slave mode.
  • I2cSlaveUnderrun - Fill(u8), Stretch { max: Second, fill: u8 }, Nack.
  • I2cSlaveEvent<'i> - Transfer(I2cSlaveTransfer) or ReadWaiting.
  • I2cSlaveTransfer<'i> - end: Second, read: bool, kind: I2cRecordKind, pointer: Option<u32>, bytes (bounded by MAX_RECORD_LEN), filled: u16, stretched: Option<Second>.
  • I2cSlaveCapabilities - map_len, max_pointer_bytes, queue_len, max_stretch: Option<Second>, nack_when_empty, ten_bit_addresses, max_freq: Hertz.
  • I2cReadKind<'i> - Plain (start, address+read, len bytes, stop) or RepeatedStart { write } (start, address+write, write, repeated start, address+read, len bytes, stop), bounded by MAX_WRITE_READ_LEN.
  • I2cOp<'i> - Write(MaxLen<RefVec<'i, u8>, MAX_WRITE_LEN>) or Read(u16), one operation of i2c_transaction (an empty Write as in i2c_write, a Read(0) is InvalidArgument).
  • I2cTransactionTimeRange - start / end: Second (start and stop condition).
  • I2cReadEnvelope<'i> - duration: I2cTransactionTimeRange, data: MaxLen<RefVec<'i, u8>, MAX_READ_LEN>.
  • I2cTransactionResult<'i> - duration, read (every I2cOp::Read's bytes, concatenated in order).
  • I2cLinesState - is_scl_high / is_sda_high: bool, scl / sda: Option<Volt> (measured level, where the adapter can).
  • I2cCapabilities<'i> - grouped by topic:
    • what it is: driver_name (bounded by MAX_NAME_LEN), master_supported, monitor_supported;
    • IO level: io_levels, io_level_min / io_level_max, pullups;
    • timing: min_freq / max_freq: Hertz, speeds (the only SCL frequencies of a controller with a few fixed ones, empty when any between min_freq and max_freq works, at most MAX_SPEEDS), stretch_timeout: Option<I2cTimeRange> (min / max of the master's stretch timeout, None when it can't be changed);
    • sizes, all u16: max_write_len, max_read_len, max_write_read_len (write part of a RepeatedStart read), max_ops (operations per i2c_transaction);
    • features: ten_bit_addresses, zero_length_write, bus_recovery. Slave mode is in the device's introspection (the slave group) and I2cSlaveCapabilities.
  • I2cWatchCapabilities - max_watches: u32 (at most MAX_WATCHES, 512), max_len (at most MAX_WATCH_LEN), min_period.
  • I2cWatchConfig<'i> - addr, kind: I2cReadKind<'i>, len, period: Second, report: I2cWatchReport<'i>.
  • I2cWatchReport<'i> - Always, Changed { mask }, Matches { bytes }, see I2cWatch above.
  • I2cMatchByte - mask, value: u8, one byte of a Matches rule; sized, two bytes.
  • I2cWatchEvent<'i> - slot, result: Result<MaxLen<RefVec<'i, u8>, MAX_WATCH_LEN>, I2cError>.
  • I2cRecord<'i> - end: Second (stop or repeated start; the start condition is the head's time), addr, read: bool, kind: I2cRecordKind, nack_at: Option<u16> (the first byte not acknowledged: Some(0) the address, Some(n) bytes[n - 1]), bytes (without the address).
  • I2cRecordKind - Complete, RepeatedStart (the next record continues the transaction), Truncated (more than MAX_RECORD_LEN bytes), BusError.
  • I2cError<'i> - Common(CommonError) (common errors), then Nack { op, at }, Arbitration, Bus, Crc, Overrun, BufferTooBig, OutOfWatchSlots, InvalidWatchSlot, ClockStretch, BusStuck { scl_low, sda_low }; unsized (unib32 discriminant), evolvable, Display from the doc lines (see below).

All enums and structs are behind #[derive_shrink_wrap] with owned(feature = "std") and cfg_attr_borrowed(feature = "defmt", derive(defmt::Format)), so owned (*Owned) variants and defmt::Format are available by enabling the crate's std / defmt features.

Errors

Every trait returns I2cError: CommonError as its #[common] Common variant (unsupported mode or IO level, invalid argument, in use, wrong state, no power, refused, timeout), then what is specific to I2C and a caller acts on:

Error What happened What a caller does
Nack { op, at } the address (at: Some(0)) or a data byte of a write was not acknowledged, see Transactions address: not there, or busy (retry an EEPROM in its page write); data: the slave refused the byte or the register
Arbitration another master won the bus retry
Bus a start or stop where it doesn't belong check the wiring, retry
ClockStretch a slave held SCL low longer than the master's stretch_timeout raise the timeout for a slow slave, or reset it
BusStuck { scl_low, sda_low } a line held low before the start: a slave stuck mid-byte, no pull-ups, a short i2c_wiggle_clock, then check i2c_lines_state
Common(Timeout) the call didn't finish within the device's own deadline, e.g. another master kept the bus retry later
Crc, Overrun SMBus PEC failed; data came faster than the adapter took it (slave mode) retry
BufferTooBig longer than the device takes split the transfer
OutOfWatchSlots, InvalidWatchSlot I2cWatch slots remove a watch; use a slot from watch_list

Each method lists the variants it returns with #[errors(..)], shown in its rustdoc and checked on the client. The enum is unsized, so a newer device's variant reads as Unknown(n) on an older host instead of failing the call.

match dev.i2c().i2c_write(addr, data.clone(), None).call().await {
    Err(CallError::Device(I2cErrorOwned::Nack { at: Some(0), .. })) => { /* not there, or busy */ }
    Err(CallError::Device(e)) if e.common().is_some() => { /* the same handling as for any other crate */ }
    r => { r?; }
}

Composing into a device API, and testing (tests/io_i2c)

A device exposes the traits it implements on its API root, see tests/io_i2c_api:

#[ww_api_root]
pub trait IoI2cTest {
    ww_impl!(i2c: ww_i2c::I2c);
    ww_impl!(i2c_watch: ww_i2c::I2cWatch);
    ww_impl!(i2c_monitor: ww_i2c::I2cMonitor);
}

tests/io_i2c runs a mock server implementing all three traits on ww_device::Server (through tests_common::start_device, the same in-process harness tests/credit_sink and tests/streams use), simulating a tiny bus of addressable byte registers in a shared Model. The generated client drives it through the real host event loop end to end:

  • i2c_write_read_roundtrip, i2c_nack_and_zero_length, i2c_scan_and_lines - I2c write/read/scan/lines/pullups, and the Nack / Common(InvalidArgument) (zero length) error paths.
  • i2c_data_nack_names_op_and_byte, i2c_clock_stretch_timeout, i2c_bus_stuck_until_recovered - where a Nack happened (operation and byte), the stretch timeout and ClockStretch, and a stuck bus reported as BusStuck until i2c_wiggle_clock frees it.
  • i2c_monitor_records_writes_and_reads - opening I2cMonitor::records turns transactions into I2cRecords on it.
  • i2c_watch_always_reports_every_read, i2c_watch_changed_reports_only_on_change, i2c_watch_matches_reports_on_transition_only, i2c_watch_error_reported_once_then_recovery_always_reports, i2c_watch_modify_remove_and_list - the three I2cWatchReport rules, the error-once-then-recovery sequence, and slot management.

Since nothing drives a real periodic timer in the mock, the test API adds one test-only method, tick_watches() -> u8, that runs a single watch-read cycle on demand (applying I2cWatchReport exactly like a real firmware's polling loop would) and returns how many watches reported - a real device would call the same internal logic from its own timer or event loop instead.