Skip to main content

I2C module

The I2C module talks to devices connected over I2C (also called TWI): sensors, EEPROM memories, OLED screens, accelerometers, clocks… You open a bus with two pins, SDA (data) and SCL (clock), and on it each device has its address. The boards have two buses, I2C.PORT0 and I2C.PORT1, which can be used at the same time with different pins.

use I2C

JARU I2C module screenshot for scanning buses and communicating with sensors and peripherals

So that you don't have to write each chip's registers by hand, JARU IDE comes with a library of drivers: it's all in I2C drivers.

Platforms​

WindowsESP32 / ESP32-S3ESP32-P4ESP32-S31
The busThere isn't one: the module pretends everything goes well (see below)The chip's twoThe chip's twoThe chip's two
Codes returned by writesAlways 00, 2, 4 or 50, 2 or 50, 2 or 5
Arguments that aren't validReturn an error valueThrow an exceptionSameSame
Default waiting time—50 ms1000 ms1000 ms
Bytes at once—Up to 128No fixed limitNo fixed limit
init on a bus that's already open—Changes nothing: it keeps the earlier pins and frequencyOpens it again with the new ones; if the board opened it, it shares itSame

On Windows there's no I2C bus: the program runs, but no device really answers. init, setTimeOut and close return true; writes, 0; read and transfer, a list of zeros of the size you ask for; readReg and readReg16, 0; and scan always finds a device at address 0x50. Each call also writes an [I2C STUB] … line to the console. Programs with I2C are tested on the board.

Functions​

FunctionWhat it doesReturns
I2C.init(port, sda, scl, frequency)Opens a bustrue or false
I2C.scan(port)Looks for the devices on the busA list of addresses
I2C.write(port, address, bytes)Sends bytes to a deviceA code: 0 if it went well
I2C.read(port, address, n)Reads n bytes from a deviceA list of bytes
I2C.transfer(port, address, bytes, n)Sends bytes and reads the answer, without releasing the busA list of bytes, or nil
I2C.transfer(port, address, bytes, n, wait)The same, with a wait between the write and the readA list of bytes, or nil
I2C.writeReg(port, address, register, value)Writes a byte to a registerA code: 0 if it went well
I2C.readReg(port, address, register)Reads a byte from a registerFrom 0 to 255, or nil
I2C.readReg16(port, address, register)Reads 16 bits from a registerFrom 0 to 65535, or nil
I2C.writeReg16(port, address, register, value)Writes 16 bits to a registerA code: 0 if it went well
I2C.setTimeOut(port, ms)Changes the bus waiting timetrue
I2C.close(port)Closes the bustrue or false

port is I2C.PORT0 or I2C.PORT1. address is the device's 7-bit address, from 0x00 to 0x7F.

On the boards, if something isn't valid, they throw an exception, which you catch with try (see Errors and exceptions):

  • ERR_INVALID_VALUE if the port isn't 0 or 1.
  • ERR_TYPE_MISMATCH if a number isn't an integer, or if a list of bytes isn't a list or holds something that isn't an integer.
  • ERR_INVALID_ARG_NUMBER if you don't pass the arguments they ask for.

A device that doesn't answer isn't an exception: the code, the nil or the empty list they return tell you so.

The write codes​

write, writeReg and writeReg16 return a code:

CodeWhat happened
0It went well.
2The device didn't answer: there's none at that address, or it didn't accept the data.
4Another error. On the ESP32 and ESP32-S3, also if the bus isn't open.
5The waiting time ran out.

On the P4 and the S31, a bus that isn't open and any other error give 2.

init(port, sda, scl, frequency)​

Opens the bus port on pins sda and scl, at frequency hertz, and returns true if it succeeds. The most used frequencies are 100000 (100 kHz, standard mode, which every device understands) and 400000 (400 kHz, fast mode).

On the ESP32 and ESP32-S3, if the bus is already open, init changes nothing and returns true: to change its pins or its frequency, close it first with close. On the P4 and the S31, if the board already had that bus open, init shares it, with the pins the board opened it with.

use I2C

// Port 0, SDA on GPIO 21, SCL on 22, at 100 kHz
if (I2C.init(I2C.PORT0, 21, 22, 100000)) then
println("Bus open")
end

Each board's I2C pins are in its profile (see Board profiles).

scan(port)​

Tries every address on the bus and returns a list with the ones that answer, from 1 to 126. Use it to find out what's connected and at which address. If the bus isn't open, it returns an empty list.

use I2C

I2C.init(I2C.PORT0, 21, 22, 100000)

var devices = I2C.scan(I2C.PORT0)
println("Devices: ", len(devices))
foreach (var addr in devices)
println(" 0x", addr.toHex())
end

write(port, address, bytes)​

Sends the bytes of the list bytes to address and returns a code. The low byte of each number is sent.

use I2C

// Turn off an OLED screen (address 0x3C)
var code = I2C.write(I2C.PORT0, 0x3C, [0x00, 0xAE])

if (code == 0) then
println("Command sent")
end

read(port, address, n)​

Reads n bytes from address and returns them in a list. It never returns nil: if the device doesn't answer, the list arrives empty or shorter. Check its size. On the ESP32 and the ESP32-S3, n goes from 0 to 128: with more, or with a negative number, it throws ERR_INVALID_VALUE, also in transfer.

use I2C

// Read 6 bytes from an accelerometer
var data = I2C.read(I2C.PORT0, 0x68, 6)

if (len(data) == 6) then
foreach (var b in data)
println(" ", b)
end
else
println("The device doesn't answer")
end

transfer(port, address, bytes, n, wait)​

Sends the bytes of the list bytes and, without releasing the bus (with a repeated start), reads n bytes of the answer. It's the usual way to read a sensor's registers: you send the register number and read its value, without another device being able to get in between.

It returns the list of bytes read, or nil if the write fails.

use I2C

// Read the WHO_AM_I register (0x75) of an MPU6050
var answer = I2C.transfer(I2C.PORT0, 0x68, [0x75], 1)

if (answer != nil) then
println("WHO_AM_I: 0x", answer[0].toHex())
end

wait, the fifth argument, is optional. Some chips don't answer inside a repeated start: they prepare the answer separately and need the write to end with STOP and some time to pass before the read (Adafruit's seesaw modules, sensors with a conversion time…). With wait, the write ends with STOP, the VM waits exactly that many microseconds and reads in a new transaction. With 0, STOP and read right away.

use I2C

// Buttons of a seesaw gamepad: GPIO module (0x01), BULK register (0x04),
// 4 bytes, with STOP and a 250 µs wait, as Adafruit's library does
var buttons = I2C.transfer(I2C.PORT0, 0x50, [0x01, 0x04], 4, 250)

The wait is a busy loop inside the VM: it's meant for tenths of a millisecond, not for long waits. It goes from 0 to 100000: a negative value counts as 0, and a larger one gives ERR_INVALID_VALUE. To wait milliseconds between two operations, use pause() between write() and read().

writeReg(port, address, register, value)​

Writes the byte value (from 0 to 255) to the register register (from 0 to 255) and returns a code. It's faster than write, because it doesn't create a list.

use I2C

// Wake up an MPU6050: 0 in its register 0x6B
var code = I2C.writeReg(I2C.PORT0, 0x68, 0x6B, 0x00)

if (code == 0) then
println("Register written")
end

readReg(port, address, register)​

Reads the byte of register register and returns it, from 0 to 255, or nil if the device doesn't answer.

use I2C

var temperature = I2C.readReg(I2C.PORT0, 0x48, 0x00)

if (temperature != nil) then
println("Raw temperature: ", temperature)
end

readReg16(port, address, register)​

Reads the two bytes starting at register register and returns them together as a 16-bit value, from 0 to 65535, or nil if the device doesn't answer.

The fourth argument, optional, says in which order the bytes come: without it (or with false), the first one is the high one (big endian), as in the MPU6050 or the BMP280; with true, the first one is the low one (little endian).

use I2C

var value = I2C.readReg16(I2C.PORT0, 0x68, 0x3B) // high byte first
var valueLE = I2C.readReg16(I2C.PORT0, 0x48, 0x00, true) // low byte first

writeReg16(port, address, register, value)​

Writes value (from 0 to 65535) in two bytes from register register on, and returns a code. As in readReg16, a fifth argument true sends the low byte first; without it, the high one.

use I2C

var code = I2C.writeReg16(I2C.PORT0, 0x50, 0x00, 0x1234) // 0x12 and then 0x34
var codeLE = I2C.writeReg16(I2C.PORT0, 0x50, 0x00, 0x1234, true) // 0x34 and then 0x12

setTimeOut(port, ms)​

Changes how long the bus waits for a device before giving it up, in milliseconds, and returns true. By default it's 50 ms on the ESP32 and ESP32-S3, and 1000 ms on the P4 and the S31. A negative value gives ERR_TYPE_MISMATCH.

use I2C

I2C.init(I2C.PORT0, 21, 22, 100000)
I2C.setTimeOut(I2C.PORT0, 100) // 100 ms

close(port)​

Closes the bus and frees its pins. On the ESP32 and ESP32-S3 it returns true with a valid port; on the P4 and the S31, true if the bus was open and false if not.

use I2C

I2C.close(I2C.PORT0)

Constants​

ConstantValueWhat it is
I2C.PORT00The first bus
I2C.PORT11The second bus

Complete example​

It reads the acceleration of an MPU6050 every tenth of a second:

use I2C

const MPU = 0x68
const PWR_MGMT = 0x6B
const ACCEL_XOUT = 0x3B

if (!I2C.init(I2C.PORT0, 21, 22, 400000)) then
println("The bus couldn't be opened")
exit(1)
end

// Is the sensor there?
var found = false
foreach (var addr in I2C.scan(I2C.PORT0))
if (addr == MPU) then
found = true
end
end
if (!found) then
println("I can't find the MPU6050")
exit(1)
end

// Wake it up
I2C.writeReg(I2C.PORT0, MPU, PWR_MGMT, 0x00)
pause(100)

while (true)
// 6 bytes: X, Y and Z, each with the high byte first
var data = I2C.transfer(I2C.PORT0, MPU, [ACCEL_XOUT], 6)

if (data != nil and len(data) == 6) then
var ax = (data[0] << 8) | data[1]
var ay = (data[2] << 8) | data[3]
var az = (data[4] << 8) | data[5]

// They are signed values
if (ax > 32767) then ax = ax - 65536 end
if (ay > 32767) then ay = ay - 65536 end
if (az > 32767) then az = az - 65536 end

println("X: ", ax, " Y: ", ay, " Z: ", az)
end

pause(100)
end

A 24C32 EEPROM​

This memory uses 16-bit addresses: you send the two bytes of the address and then the data.

use I2C

const EEPROM = 0x50

I2C.init(I2C.PORT0, 21, 22, 100000)

// Write 0x42 at address 0x0000
var code = I2C.write(I2C.PORT0, EEPROM, [0x00, 0x00, 0x42])

if (code == 0) then
pause(10) // the EEPROM takes a while to store it

// Read it: first the address, then the data
I2C.write(I2C.PORT0, EEPROM, [0x00, 0x00])
var stored = I2C.read(I2C.PORT0, EEPROM, 1)
if (len(stored) == 1) then
println("Data: 0x", stored[0].toHex())
end
end

Pitfalls and known issues​

  • Pull-up resistors. The bus needs pull-up resistors on SDA and SCL (usually from 4.7 kΩ to 10 kΩ). Many modules already have them; if you connect the chips directly, add them yourself.
  • 7-bit addresses. Some datasheets give the address in 8 bits, with the read or write bit. If so, divide it by 2.
  • read doesn't return nil. If the device doesn't answer, the list arrives empty or shorter: check its size.
  • 128 bytes at once on the ESP32 and ESP32-S3. In a write, anything beyond 128 bytes isn't sent, without warning, and a read of more than 128, or of a negative length, throws ERR_INVALID_VALUE. Split it into chunks.
  • An open bus isn't reopened on the ESP32 and ESP32-S3. A second init on the same port keeps the earlier pins and frequency: call close first.
  • On Windows, everything goes well. The Windows stand-in always answers: a program that works on Windows may not find the device on the board.

All the known issues of JARU, together, are in Known issues.