Skip to content

Berry I2C

How to use the generic I2C API in Berry Script on firmware 0.12.11 for reading from and writing to custom devices.

Berry I2C exposes the controller’s generic I2C bus as the io["I2C"] object.

This means that many I2C devices no longer need a new hardcoded driver in firmware. Instead, you can:

  • define I2C in Controller Config
  • find the device from Berry Script, then read and write its registers
  • build a small project-specific driver directly in the project

Typical use cases include:

  • ambient light sensors
  • accelerometers or IMUs
  • capacitive touch controllers
  • other I2C peripheral chips with a known datasheet

Berry I2C works on top of one IO with type I2C, defined in Controller Config.

Example:

{
"I2C": {
"type": "I2C",
"frequency": 400000,
"sda": 27,
"scl": 14
}
}

Important:

  • the current firmware version supports one Berry I2C bus per controller
  • if I2C is not present in the config, io["I2C"] will not be usable by your script

Berry Script gets the I2C bus like this:

var bus = io["I2C"]

Once you have bus, you can call the generic I2C methods on it.

Returns a list of detected 7-bit I2C addresses.

var bus = io["I2C"]
print("scan:", bus.scan())

Checks whether a device responds at the given address.

if bus.probe(0x10)
print("device found")
end

Performs a plain I2C read with length len.

Use it when the device expects a read without a register prefix.

read(addr, len) does not set the register address by itself. If you need to write a register address first and then read, use writeRead(...) or readReg(...).

Writes a byte payload to the given address and returns the number of written bytes.

var tx = bytes().append(0x03).append(0x00).append(0x00)
bus.write(0x10, tx)

Performs a write-then-read transaction. This is the common way to work with registers on many I2C devices.

var data = bus.writeRead(0x10, bytes().append(0x04), 2)

Convenience helper for reading a register.

var data = bus.readReg(0x10, 0x04, 2)

Convenience helper for writing a register.

data can be:

  • a single byte number
  • a bytes object
bus.writeReg(0x10, 0x03, 0x00)
bus.writeReg(0x10, 0x00, bytes().append(0x00).append(0x00))

The Berry I2C layer intentionally works only with a byte payload.

You interpret values in Berry Script according to the device datasheet:

  • little-endian vs. big-endian
  • signed vs. unsigned
  • converting registers into physical units
  • custom initialization sequences

That is intentional. Firmware provides the generic transport, and the project driver is composed in Berry.

This example:

  • checks that something responds at address 0x10
  • reads a 16-bit little-endian value from register 0x04
  • prints it to the log
var bus = io["I2C"]
if !bus.probe(0x10)
print("I2C device 0x10 not found")
return
end
var raw = bus.readReg(0x10, 0x04, 2)
var value = raw[0] | (raw[1] << 8)
print("raw:", value)

For VEML7700, you can create a very small project-specific driver directly in Berry Script.

The following example:

  • checks that the device is present
  • sets the basic configuration
  • prints the ALS lux value once per second
var bus = io["I2C"]
var ok = false
var last = -1000
try
if bus != nil && bus.probe(0x10)
# ALS_CONF = 0x0000 => gain 1x, integration 100 ms
bus.writeReg(0x10, 0x00, bytes().append(0x00).append(0x00))
# Power saving off
bus.writeReg(0x10, 0x03, 0x00)
ok = true
end
except ..
ok = false
end
Plugin(def()
if !ok || controller.millis() - last < 1000
return
end
last = controller.millis()
try
var raw = bus.readReg(0x10, 0x04, 2)
var als = raw[0] | (raw[1] << 8)
var lux = als * 0.0576
print("VEML7700 lux:", lux)
except ..
print("VEML7700 read failed")
end
end)

Below are examples in the style that has worked well in Spectoda projects:

  • comments explain what is happening
  • the code itself stays short and economical
  • variable names are intentionally compact so the script does not consume unnecessary RAM

This plugin:

  • checks VEML7700 at address 0x10
  • sets the basic configuration
  • prints the lux value once per second
var b = io["I2C"]
var ok = false
var last = -1000
# ALS_CONF = 0x0000 => gain 1x, integration 100 ms.
# PSM = 0x00 => normal mode.
try
if b != nil && b.probe(0x10)
b.writeReg(0x10, 0x00, bytes().append(0x00).append(0x00))
b.writeReg(0x10, 0x03, 0x00)
ok = true
end
except ..
ok = false
end
Plugin(def()
if !ok || controller.millis() - last < 1000
return
end
last = controller.millis()
try
var d = b.readReg(0x10, 0x04, 2)
var raw = d[0] | (d[1] << 8)
var lux = raw * 0.0576
print("VEML7700 lux:", lux)
except ..
print("VEML7700 read failed")
end
end)

VEML7700: Daylight plugin as a drop-in replacement

Section titled “VEML7700: Daylight plugin as a drop-in replacement”

This example is a compact replacement for an older plugin that used firmware-specific helpers.

The behavior stays the same:

  • it reads the same NUMBER values in milli-lux as the original helper
  • it publishes DAYLI
  • on failure, it emits E_INI or E_REA
  • it adjusts brigh according to the target lux value
def Daylight(S)
import controller, math
var id = S["id"]
var target = S["target"]
var toggl = EVS("toggl", id)
var brigh = EVS("brigh", id)
var L_ena = EVS("L_ena", id)
# The original helper used fixed gain 1x and integration 100 ms.
# If you change this, keep the raw -> milli-lux conversion in sync.
var als_conf = 0x0000
var mlux_per_count_x10 = 576
var bus = nil
var ok = false
var err_init = EVT("DAYLI", "E_INI", id, LABEL)
var err_read = EVT("DAYLI", "E_REA", id, LABEL)
# Direct replacement for the old __vmin("I2C", 1.0, 100):
# one fixed sensor init without auto-range.
try
bus = io["I2C"]
if bus != nil
var cfg = bytes()
cfg.append(als_conf & 0xff)
cfg.append((als_conf >> 8) & 0xff)
bus.writeReg(0x10, 0x00, cfg)
bus.writeReg(0x10, 0x03, 0x00)
ok = true
end
except ..
ok = false
end
if !ok
err_init()
end
# Direct replacement for the old __vmre():
# reads the ALS register and returns NUMBER in milli-lux.
var rd = def()
if !ok
return nil
end
try
var d = bus.readReg(0x10, 0x04, 2)
var raw = d[0] | (d[1] << 8)
return int((raw * mlux_per_count_x10) / 10)
except ..
return nil
end
end
EVS("DAYLI", id).cb = def(v)
if v.is(NULL)
var lux = rd()
if lux == nil
err_read()
else
EVS.emit("DAYLI", lux, id, NUMBER)
end
return
end
end
var last = controller.millis()
return Plugin(def()
var now = controller.millis()
if now - last < 1000 || !L_ena || !toggl
return
end
last = now
var lux = rd()
if lux == nil
err_read()
return
end
var dis = math.abs(lux - target)
if dis == 0 || lux == 0
return
end
var dif = real(dis) / real(lux)
if dif < 0.025 || dis < 10
return
end
var b0 = brigh.get(PERCENTAGE)
if lux < target
if dif > 0.1
b0 += 5
else
b0 += 1
end
else
if dif > 0.1
b0 -= 5
else
b0 -= 1
end
end
if b0 < 0
b0 = 0
elif b0 > 100
b0 = 100
end
brigh.set(b0, PERCENTAGE)
end)
end

This example shows how to connect MPR121 through Berry I2C.

The example:

  • uses the default address 0x5A
  • sets the touch and release threshold for all 12 electrodes
  • reads the touched mask from registers 0x00 and 0x01
  • toggles toggl on the rising edge of a touch on the selected electrode

If your ADDR pin is wired differently, the address may also be 0x5B, 0x5C, or 0x5D instead of 0x5A.

In practice, it helps to know that:

  • MPR121 returns touch state as a 12-bit mask
  • bit 0 corresponds to electrode 0, bit 3 corresponds to electrode 3
  • for touch control, it is usually best to react only to the rising edge, not to the full duration of the touch
import controller
class I2CDevice
var bus, addr
def init(bus, addr)
self.bus = bus
self.addr = addr
end
def probe()
return self.bus != nil && self.bus.probe(self.addr)
end
def read_reg(reg, len)
return self.bus.readReg(self.addr, reg, len)
end
def write_reg(reg, data)
return self.bus.writeReg(self.addr, reg, data)
end
def read_u16le(reg)
var data = self.read_reg(reg, 2)
return data[0] | (data[1] << 8)
end
end
class MPR121 : I2CDevice
def init(bus, addr)
super(self).init(bus, addr == nil ? 0x5a : addr)
end
def begin(touch_threshold, release_threshold)
if !self.probe()
raise "runtime_error", "MPR121 not found on I2C bus"
end
# Soft reset and stop mode before configuration.
self.write_reg(0x80, 0x63)
self.write_reg(0x5e, 0x00)
self.set_thresholds(
touch_threshold == nil ? 12 : touch_threshold,
release_threshold == nil ? 6 : release_threshold,
)
# Basic recommended filtering and autoconfig.
self.write_reg(0x2b, 0x01)
self.write_reg(0x2c, 0x01)
self.write_reg(0x2d, 0x00)
self.write_reg(0x2e, 0x00)
self.write_reg(0x5d, 0x04)
# Enable 12 electrodes, run mode.
self.write_reg(0x5e, 0x8c)
return self
end
def set_thresholds(touch_threshold, release_threshold)
for electrode : 0 .. 11
self.write_reg(0x41 + electrode * 2, touch_threshold & 0xff)
self.write_reg(0x42 + electrode * 2, release_threshold & 0xff)
end
end
def touched_mask()
return self.read_u16le(0x00) & 0x0fff
end
def is_touched(electrode)
if electrode < 0 || electrode > 11
raise "value_error", "electrode must be between 0 and 11"
end
return ((self.touched_mask() >> electrode) & 0x01) == 1
end
end
controller.ups = 100
var touch_electrode = 3
var sensor = nil
var last_mask = 0
var last_poll = -1000
var toggl = EVS("toggl")
var mprok = EVS("mprok")
var mperr = EVS("mperr")
var mpmsk = EVS("mpmsk")
def ensure_sensor()
if sensor != nil
return sensor
end
sensor = MPR121(io["I2C"], 0x5a).begin(12, 6)
last_mask = sensor.touched_mask()
mprok.set(true, BOOLEAN)
mperr.set(0, NUMBER)
if !toggl.is(BOOLEAN)
toggl.set(false, BOOLEAN)
end
return sensor
end
Plugin(def()
if controller.millis() - last_poll < 20
return
end
last_poll = controller.millis()
try
var dev = ensure_sensor()
var mask = dev.touched_mask()
var rising = mask & (~last_mask)
mpmsk.set(mask, NUMBER)
if (rising & (1 << touch_electrode)) != 0
toggl.set(!toggl.get(BOOLEAN), BOOLEAN)
end
last_mask = mask
mperr.set(0, NUMBER)
except ..
sensor = nil
mprok.set(false, BOOLEAN)
mperr.set(1, NUMBER)
end
end)

This plugin is especially practical when:

  • you need to turn a capacitive touch pad into a simple button
  • you first want to verify that I2C communication works before adding more complex logic
  • you need to temporarily expose diagnostics through mprok, mperr, and mpmsk

This plugin shows how to connect LSM6DS3TR through Berry I2C.

The example:

  • uses the default I2C address 0x6A
  • checks WHO_AM_I
  • enables the accelerometer and gyroscope at 104 Hz
  • continuously prints acceleration in g and gyroscope values in dps

If your SA0 pin is wired differently, you may need to change the address to 0x6B.

var b = io["I2C"]
var ok = false
var last = -1000
# Convert two bytes into signed int16.
var s16 = def(lo, hi)
var v = lo | (hi << 8)
if v >= 0x8000
v -= 0x10000
end
return v
end
# Default address is 0x6A when SA0 is low.
# WHO_AM_I should return 0x6A.
# CTRL3_C = 0x44 enables BDU and register auto-increment.
# CTRL1_XL = 0x40 enables accelerometer at 104 Hz, +/-2 g.
# CTRL2_G = 0x40 enables gyroscope at 104 Hz, +/-245 dps.
try
if b != nil && b.probe(0x6A)
if b.readReg(0x6A, 0x0F, 1)[0] == 0x6A
b.writeReg(0x6A, 0x12, 0x44)
b.writeReg(0x6A, 0x10, 0x40)
b.writeReg(0x6A, 0x11, 0x40)
ok = true
end
end
except ..
ok = false
end
Plugin(def()
if !ok || controller.millis() - last < 500
return
end
last = controller.millis()
try
# Read gyro XYZ and accel XYZ in one burst.
var d = b.readReg(0x6A, 0x22, 12)
var gx = s16(d[0], d[1]) * 0.00875
var gy = s16(d[2], d[3]) * 0.00875
var gz = s16(d[4], d[5]) * 0.00875
var ax = s16(d[6], d[7]) * 0.000061
var ay = s16(d[8], d[9]) * 0.000061
var az = s16(d[10], d[11]) * 0.000061
print("LSM6DS3TR acc[g]:", ax, ay, az, "gyro[dps]:", gx, gy, gz)
except ..
print("LSM6DS3TR read failed")
end
end)

When you connect a new I2C device, the proven workflow is:

  1. First verify scan() and probe(addr).
  2. Then manually read or write the first registers through readReg(...) and writeReg(...).
  3. Only after that, wrap the logic into a small Berry driver for the specific device.

In practice, this means:

  • firmware handles the generic I2C transport
  • your Berry Script handles the device-specific protocol
  • changing the device often does not require changing the C++ firmware

This is useful mainly for projects where you need to add support for a specific sensor or peripheral chip quickly.

When to use readReg(...) and when to use read(...)

Section titled “When to use readReg(...) and when to use read(...)”

In most cases, you work with registers, so it makes sense to use:

  • readReg(...)
  • writeReg(...)
  • optionally writeRead(...)

Use plain read(...) only when the device really expects a read without a register address, or when you know exactly how the chip behaves in that situation.