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:
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; seeI2cCapabilitiesunder 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 oneI2cConfig(Noneleaves 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_aftermakes 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 withI2cCapabilities::zero_length_write,Common(Unsupported)on the others.i2c_read- plain read, or a register read (write a few bytes, repeated start, read) throughI2cReadKind.i2c_scan- 7-bit addresses that acknowledged a one-byte read, ascending, skipping the reserved ranges (0x00..=0x07,0x78..=0x7F).i2c_transaction- severalI2cOpwrites/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 tocounttimes (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 withBusStuck, 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 (seeI2cCapabilities::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 mostI2cWatchCapabilities::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, asI2cWatchEvent. 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 }- whendata & maskdiffers from the previous reported read's (maskas long as the read; all-ones to see any change).Matches { bytes }- whendata[i] & bytes[i].mask == bytes[i].valuefor every byte starts or stops being true (an alert bit set, a status reaching a state) - a transition, not every read while it holds. OneI2cMatchByte { mask, value }per byte read, two bytes each on the wire; amaskof0x00ignores 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 withslave_map_write/slave_map_read, before and while the slave runs. The firstpointer_bytesbytes 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 atlen, writes within theirpageas 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 theslave_answerscredit 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)- sendsbytefor every byte read (0xFFis what a released bus reads); the master never waits.Stretch { max, fill }- holds SCL low until an answer comes, at mostmaxper read, then sendsfillfor the rest. The answer that comes late goes to the next read, andI2cSlaveTransfer::filledtells 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 ofi2c_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 withClockStretch; a device always enforces one (a stuck SCL never hangs it), so its answer is alwaysSome. Slave mode isslave_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 }orQueue { empty: I2cSlaveUnderrun }, see slave mode.I2cSlaveUnderrun-Fill(u8),Stretch { max: Second, fill: u8 },Nack.I2cSlaveEvent<'i>-Transfer(I2cSlaveTransfer)orReadWaiting.I2cSlaveTransfer<'i>-end: Second,read: bool,kind: I2cRecordKind,pointer: Option<u32>,bytes(bounded byMAX_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,lenbytes, stop) orRepeatedStart { write }(start, address+write,write, repeated start, address+read,lenbytes, stop), bounded byMAX_WRITE_READ_LEN.I2cOp<'i>-Write(MaxLen<RefVec<'i, u8>, MAX_WRITE_LEN>)orRead(u16), one operation ofi2c_transaction(an emptyWriteas ini2c_write, aRead(0)isInvalidArgument).I2cTransactionTimeRange-start/end: Second(start and stop condition).I2cReadEnvelope<'i>-duration: I2cTransactionTimeRange,data: MaxLen<RefVec<'i, u8>, MAX_READ_LEN>.I2cTransactionResult<'i>-duration,read(everyI2cOp::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 byMAX_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 betweenmin_freqandmax_freqworks, at mostMAX_SPEEDS),stretch_timeout: Option<I2cTimeRange>(min/maxof the master's stretch timeout,Nonewhen it can't be changed); - sizes, all
u16:max_write_len,max_read_len,max_write_read_len(write part of aRepeatedStartread),max_ops(operations peri2c_transaction); - features:
ten_bit_addresses,zero_length_write,bus_recovery. Slave mode is in the device's introspection (theslavegroup) andI2cSlaveCapabilities.
- what it is:
I2cWatchCapabilities-max_watches: u32(at mostMAX_WATCHES, 512),max_len(at mostMAX_WATCH_LEN),min_period.I2cWatchConfig<'i>-addr,kind: I2cReadKind<'i>,len,period: Second,report: I2cWatchReport<'i>.I2cWatchReport<'i>-Always,Changed { mask },Matches { bytes }, seeI2cWatchabove.I2cMatchByte-mask,value: u8, one byte of aMatchesrule;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 thanMAX_RECORD_LENbytes),BusError.I2cError<'i>-Common(CommonError)(common errors), thenNack { op, at },Arbitration,Bus,Crc,Overrun,BufferTooBig,OutOfWatchSlots,InvalidWatchSlot,ClockStretch,BusStuck { scl_low, sda_low }; unsized (unib32discriminant), evolvable,Displayfrom 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-I2cwrite/read/scan/lines/pullups, and theNack/Common(InvalidArgument)(zero length) error paths.i2c_data_nack_names_op_and_byte,i2c_clock_stretch_timeout,i2c_bus_stuck_until_recovered- where aNackhappened (operation and byte), the stretch timeout andClockStretch, and a stuck bus reported asBusStuckuntili2c_wiggle_clockfrees it.i2c_monitor_records_writes_and_reads- openingI2cMonitor::recordsturns transactions intoI2cRecords 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 threeI2cWatchReportrules, 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.