hw-i2c: add I2C peripheral gems for embedded platforms

Add three new gems for I2C communication:
- hw-i2c: common Ruby API, C bindings, and HAL header
- hw-esp32-i2c: ESP32 HAL using ESP-IDF I2C master driver
- hw-rp2040-i2c: RP2040 HAL using Pico SDK

The HAL API provides init, read, write, and atomic write_read
(repeated START) operations. Platform gems depend on hw-i2c
and are only compiled when explicitly included in build config.

Co-authored-by: Claude <noreply@anthropic.com>
This commit is contained in:
Yukihiro "Matz" Matsumoto
2026-03-27 10:12:07 +09:00
parent 416793db3d
commit 1ed52461e8
9 changed files with 637 additions and 0 deletions
+7
View File
@@ -0,0 +1,7 @@
MRuby::Gem::Specification.new('hw-esp32-i2c') do |spec|
spec.license = 'MIT'
spec.author = 'mruby developers'
spec.summary = 'I2C HAL for ESP32'
spec.add_dependency 'hw-i2c'
end
+122
View File
@@ -0,0 +1,122 @@
#include <string.h>
#include "driver/i2c_master.h"
#include <mruby/i2c.h>
typedef struct {
i2c_master_bus_handle_t bus;
uint32_t freq;
bool initialized;
} i2c_ctx;
/* ESP32 supports up to 2 I2C ports */
static i2c_ctx ctx[2];
static bool
valid_unit(int unit)
{
return unit >= 0 && unit <= 1 && ctx[unit].initialized;
}
static uint32_t
us_to_ms(uint32_t timeout_us)
{
uint32_t ms = (timeout_us + 999) / 1000;
return (ms < 10) ? 10 : ms;
}
static i2c_master_dev_handle_t
add_device(int unit, uint8_t addr)
{
i2c_device_config_t cfg = {
.dev_addr_length = I2C_ADDR_BIT_LEN_7,
.device_address = addr,
.scl_speed_hz = ctx[unit].freq,
};
i2c_master_dev_handle_t dev;
if (i2c_master_bus_add_device(ctx[unit].bus, &cfg, &dev) != ESP_OK)
return NULL;
return dev;
}
int
mrb_i2c_unit_name_to_num(const char *name)
{
if (strcmp(name, "ESP32_I2C0") == 0) return 0;
if (strcmp(name, "ESP32_I2C1") == 0) return 1;
return MRB_I2C_ERROR_UNIT;
}
mrb_i2c_status
mrb_i2c_init(int unit, uint32_t freq, int8_t sda, int8_t scl)
{
if (unit < 0 || unit > 1) return MRB_I2C_ERROR_UNIT;
if (ctx[unit].initialized) {
i2c_del_master_bus(ctx[unit].bus);
ctx[unit].initialized = false;
}
i2c_master_bus_config_t cfg = {
.clk_source = I2C_CLK_SRC_DEFAULT,
.i2c_port = unit,
.scl_io_num = scl,
.sda_io_num = sda,
.glitch_ignore_cnt = 7,
.flags.enable_internal_pullup = true,
};
esp_err_t err = i2c_new_master_bus(&cfg, &ctx[unit].bus);
if (err != ESP_OK) return MRB_I2C_ERROR_UNIT;
ctx[unit].initialized = true;
ctx[unit].freq = freq;
return MRB_I2C_OK;
}
int
mrb_i2c_read(int unit, uint8_t addr, uint8_t *dst, size_t len,
uint32_t timeout_us)
{
if (!valid_unit(unit)) return MRB_I2C_ERROR_UNIT;
i2c_master_dev_handle_t dev = add_device(unit, addr);
if (!dev) return -1;
esp_err_t err = i2c_master_receive(dev, dst, len, us_to_ms(timeout_us));
i2c_master_bus_rm_device(dev);
return (err == ESP_OK) ? (int)len : -1;
}
int
mrb_i2c_write(int unit, uint8_t addr, const uint8_t *src, size_t len,
uint32_t timeout_us)
{
if (!valid_unit(unit)) return MRB_I2C_ERROR_UNIT;
i2c_master_dev_handle_t dev = add_device(unit, addr);
if (!dev) return -1;
esp_err_t err = i2c_master_transmit(dev, src, len, us_to_ms(timeout_us));
i2c_master_bus_rm_device(dev);
return (err == ESP_OK) ? (int)len : -1;
}
int
mrb_i2c_write_read(int unit, uint8_t addr, const uint8_t *src, size_t wlen,
uint8_t *dst, size_t rlen, uint32_t timeout_us)
{
if (!valid_unit(unit)) return MRB_I2C_ERROR_UNIT;
i2c_master_dev_handle_t dev = add_device(unit, addr);
if (!dev) return -1;
esp_err_t err = i2c_master_transmit_receive(dev, src, wlen, dst, rlen,
us_to_ms(timeout_us));
i2c_master_bus_rm_device(dev);
return (err == ESP_OK) ? (int)rlen : -1;
}
#include <mruby.h>
void mrb_hw_esp32_i2c_gem_init(mrb_state *mrb) {}
void mrb_hw_esp32_i2c_gem_final(mrb_state *mrb) {}
+175
View File
@@ -0,0 +1,175 @@
# hw-i2c - I2C peripheral interface for mruby
This gem provides the `I2C` class for communicating with I2C devices from mruby. It is designed for embedded platforms such as ESP32 and RP2040.
## Architecture
The I2C support is split into a common gem and platform-specific HAL gems:
- **hw-i2c** (this gem) - Ruby API, C bindings, and HAL function declarations
- **hw-esp32-i2c** - HAL implementation for ESP32 (using ESP-IDF I2C master driver)
- **hw-rp2040-i2c** - HAL implementation for RP2040 (using Pico SDK)
The platform gems depend on hw-i2c, so you only need to specify the platform gem in your build configuration.
## Build Configuration
```ruby
# For ESP32
MRuby::CrossBuild.new('esp32') do |conf|
# ...
conf.gem "#{root}/mrbgems/hw-esp32-i2c"
end
# For RP2040
MRuby::CrossBuild.new('rp2040') do |conf|
# ...
conf.gem "#{root}/mrbgems/hw-rp2040-i2c"
end
```
## Ruby API
### I2C.new
```ruby
i2c = I2C.new(
unit: :ESP32_I2C0, # I2C unit name (platform-specific, required)
frequency: 100_000, # bus frequency in Hz (default: 100kHz)
sda_pin: 21, # SDA GPIO pin number (default: -1 for platform default)
scl_pin: 22, # SCL GPIO pin number (default: -1 for platform default)
timeout: 500 # default timeout in ms (default: 500)
)
```
#### Unit Names
| Platform | Available Units |
|----------|---------------------------------|
| ESP32 | `:ESP32_I2C0`, `:ESP32_I2C1` |
| RP2040 | `:RP2040_I2C0`, `:RP2040_I2C1` |
On RP2040, if `sda_pin` or `scl_pin` is -1, the Pico SDK default pins are used.
### I2C#write
Write data to an I2C device.
```ruby
i2c.write(addr, *data, timeout: 500)
```
- `addr` - 7-bit I2C device address (Integer)
- `data` - one or more data arguments, each can be:
- **Integer** - a single byte (0-255)
- **Array of Integer** - multiple bytes
- **String** - raw bytes
- `timeout:` - optional timeout in ms (overrides instance default)
- Returns the number of bytes written (Integer)
- Raises `IOError` on failure
```ruby
# Write a single byte
i2c.write(0x3C, 0x00)
# Write multiple bytes
i2c.write(0x3C, 0x00, [0xAE, 0xD5, 0x80])
# Write a string
i2c.write(0x3C, "hello")
# Mix data types
i2c.write(0x3C, 0x40, [0x01, 0x02], "data")
```
### I2C#read
Read data from an I2C device. Optionally write data before reading (repeated START).
```ruby
i2c.read(addr, length, *write_data, timeout: 500)
```
- `addr` - 7-bit I2C device address (Integer)
- `length` - number of bytes to read (Integer, must be positive)
- `write_data` - optional data to write before reading (same format as `write`). When provided, the gem performs a write-then-read transaction using I2C repeated START condition. This is the standard way to read from a specific register.
- `timeout:` - optional timeout in ms (overrides instance default)
- Returns the data read (String)
- Raises `IOError` on failure, `ArgumentError` if length <= 0
```ruby
# Simple read (2 bytes from device)
data = i2c.read(0x50, 2)
# Register read: write register address 0x00, then read 2 bytes
data = i2c.read(0x50, 2, 0x00)
# Multi-byte register address
data = i2c.read(0x50, 4, [0x00, 0x10])
```
### I2C#scan
Scan the I2C bus for responsive devices.
```ruby
i2c.scan(timeout: 500)
```
- `timeout:` - optional timeout per probe in ms
- Returns an Array of 7-bit addresses (Integer) that responded
```ruby
found = i2c.scan
# => [0x3C, 0x50, 0x68]
```
## HAL Interface
To add support for a new platform, create a gem (e.g., `hw-myboard-i2c`) that depends on `hw-i2c` and implements the following C functions declared in `<mruby/i2c.h>`:
```c
/* Convert platform-specific unit name string to unit number.
Return MRB_I2C_ERROR_UNIT for unknown names. */
int mrb_i2c_unit_name_to_num(const char *name);
/* Initialize an I2C bus unit.
sda/scl: GPIO pin numbers (-1 for platform default if available).
Return MRB_I2C_OK on success. */
mrb_i2c_status mrb_i2c_init(int unit, uint32_t freq, int8_t sda, int8_t scl);
/* Read len bytes from device at addr.
Return number of bytes read on success, negative on error. */
int mrb_i2c_read(int unit, uint8_t addr, uint8_t *dst, size_t len,
uint32_t timeout_us);
/* Write len bytes to device at addr.
Return number of bytes written on success, negative on error. */
int mrb_i2c_write(int unit, uint8_t addr, const uint8_t *src, size_t len,
uint32_t timeout_us);
/* Atomic write-then-read using repeated START condition.
Write wlen bytes from src, then read rlen bytes into dst.
Return number of bytes read on success, negative on error. */
int mrb_i2c_write_read(int unit, uint8_t addr,
const uint8_t *src, size_t wlen,
uint8_t *dst, size_t rlen,
uint32_t timeout_us);
```
The gem must also provide empty `mrb_<gemname>_gem_init()` and `mrb_<gemname>_gem_final()` functions (with hyphens replaced by underscores).
### Error Codes
```c
typedef enum {
MRB_I2C_OK = 0,
MRB_I2C_ERROR_UNIT = -1, /* invalid or uninitialized unit */
MRB_I2C_ERROR_TIMEOUT = -2, /* communication timeout */
MRB_I2C_ERROR_NACK = -3, /* device did not acknowledge */
} mrb_i2c_status;
```
## License
MIT
+31
View File
@@ -0,0 +1,31 @@
#ifndef MRUBY_I2C_H
#define MRUBY_I2C_H
#include <stdint.h>
#include <stddef.h>
#include <stdbool.h>
#ifdef __cplusplus
extern "C" {
#endif
typedef enum {
MRB_I2C_OK = 0,
MRB_I2C_ERROR_UNIT = -1,
MRB_I2C_ERROR_TIMEOUT = -2,
MRB_I2C_ERROR_NACK = -3,
} mrb_i2c_status;
/* HAL functions - implemented by hw-<platform>-i2c gems */
mrb_i2c_status mrb_i2c_init(int unit, uint32_t freq, int8_t sda, int8_t scl);
int mrb_i2c_read(int unit, uint8_t addr, uint8_t *dst, size_t len, uint32_t timeout_us);
int mrb_i2c_write(int unit, uint8_t addr, const uint8_t *src, size_t len, uint32_t timeout_us);
int mrb_i2c_write_read(int unit, uint8_t addr, const uint8_t *src, size_t wlen,
uint8_t *dst, size_t rlen, uint32_t timeout_us);
int mrb_i2c_unit_name_to_num(const char *name);
#ifdef __cplusplus
}
#endif
#endif /* MRUBY_I2C_H */
+5
View File
@@ -0,0 +1,5 @@
MRuby::Gem::Specification.new('hw-i2c') do |spec|
spec.license = 'MIT'
spec.authors = ['HASUMI Hitoshi', 'mruby developers']
spec.summary = 'I2C peripheral interface'
end
+21
View File
@@ -0,0 +1,21 @@
class I2C
DEFAULT_FREQUENCY = 100_000 # Hz
DEFAULT_TIMEOUT = 500 # ms
def initialize(unit:, frequency: DEFAULT_FREQUENCY, sda_pin: -1, scl_pin: -1, timeout: DEFAULT_TIMEOUT)
@timeout = timeout
@unit_num = __init(unit.to_s, frequency, sda_pin, scl_pin)
end
def scan(timeout: @timeout)
found = []
(0x08..0x77).each do |addr|
begin
read(addr, 1, timeout: timeout)
found << addr
rescue IOError
end
end
found
end
end
+200
View File
@@ -0,0 +1,200 @@
#include <string.h>
#include <mruby.h>
#include <mruby/presym.h>
#include <mruby/variable.h>
#include <mruby/array.h>
#include <mruby/string.h>
#include <mruby/i2c.h>
#define STACK_BUF_SIZE 256
#define E_IO_ERROR mrb_exc_get_id(mrb, MRB_SYM(IOError))
static size_t
i2c_fill_buf(mrb_state *mrb, uint8_t *buf, mrb_value *args, mrb_int argc)
{
size_t pos = 0;
for (mrb_int i = 0; i < argc; i++) {
switch (mrb_type(args[i])) {
case MRB_TT_ARRAY: {
mrb_int alen = RARRAY_LEN(args[i]);
const mrb_value *aptr = RARRAY_PTR(args[i]);
for (mrb_int j = 0; j < alen; j++) {
if (!mrb_integer_p(aptr[j])) {
mrb_raise(mrb, E_TYPE_ERROR, "array element must be Integer");
}
buf[pos++] = (uint8_t)mrb_integer(aptr[j]);
}
break;
}
case MRB_TT_INTEGER:
buf[pos++] = (uint8_t)mrb_integer(args[i]);
break;
case MRB_TT_STRING:
memcpy(&buf[pos], RSTRING_PTR(args[i]), RSTRING_LEN(args[i]));
pos += RSTRING_LEN(args[i]);
break;
default:
break;
}
}
return pos;
}
static size_t
i2c_calc_size(mrb_state *mrb, mrb_value *args, mrb_int argc)
{
size_t total = 0;
for (mrb_int i = 0; i < argc; i++) {
switch (mrb_type(args[i])) {
case MRB_TT_ARRAY:
total += RARRAY_LEN(args[i]);
break;
case MRB_TT_INTEGER:
total += 1;
break;
case MRB_TT_STRING:
total += RSTRING_LEN(args[i]);
break;
default:
mrb_raise(mrb, E_TYPE_ERROR, "Integer, Array, or String expected");
}
}
return total;
}
/* Allocate write buffer, fill it, return pointer and size.
Caller must free if need_free is set. */
static uint8_t*
i2c_build_buf(mrb_state *mrb, mrb_value *args, mrb_int argc,
size_t *out_len, uint8_t *sbuf, mrb_bool *need_free)
{
size_t total = i2c_calc_size(mrb, args, argc);
uint8_t *buf;
if (total <= STACK_BUF_SIZE) {
buf = sbuf;
*need_free = FALSE;
}
else {
buf = (uint8_t*)mrb_malloc(mrb, total);
*need_free = TRUE;
}
i2c_fill_buf(mrb, buf, args, argc);
*out_len = total;
return buf;
}
static mrb_int
get_timeout(mrb_state *mrb, mrb_value self, mrb_value kw)
{
if (mrb_undef_p(kw)) {
return mrb_integer(mrb_iv_get(mrb, self, MRB_IVSYM(timeout)));
}
return mrb_integer(kw);
}
static mrb_value
mrb_i2c_m_write(mrb_state *mrb, mrb_value self)
{
mrb_value *args;
mrb_int argc, addr;
const mrb_sym kw_names[] = { MRB_SYM(timeout) };
mrb_value kw_values[1];
mrb_kwargs kwargs = { 1, 0, kw_names, kw_values, NULL };
mrb_get_args(mrb, "i*:", &addr, &args, &argc, &kwargs);
mrb_int timeout_ms = get_timeout(mrb, self, kw_values[0]);
mrb_int unit = mrb_integer(mrb_iv_get(mrb, self, MRB_IVSYM(unit_num)));
uint8_t sbuf[STACK_BUF_SIZE];
size_t wlen;
mrb_bool need_free;
uint8_t *buf = i2c_build_buf(mrb, args, argc, &wlen, sbuf, &need_free);
int ret = mrb_i2c_write((int)unit, (uint8_t)addr, buf, wlen,
(uint32_t)timeout_ms * 1000);
if (need_free) mrb_free(mrb, buf);
if (ret < 0) {
mrb_raise(mrb, E_IO_ERROR, "I2C write failed");
}
return mrb_fixnum_value(ret);
}
static mrb_value
mrb_i2c_m_read(mrb_state *mrb, mrb_value self)
{
mrb_value *args;
mrb_int argc, addr, len;
const mrb_sym kw_names[] = { MRB_SYM(timeout) };
mrb_value kw_values[1];
mrb_kwargs kwargs = { 1, 0, kw_names, kw_values, NULL };
mrb_get_args(mrb, "ii*:", &addr, &len, &args, &argc, &kwargs);
if (len <= 0) {
mrb_raise(mrb, E_ARGUMENT_ERROR, "read length must be positive");
}
mrb_int timeout_ms = get_timeout(mrb, self, kw_values[0]);
mrb_int unit = mrb_integer(mrb_iv_get(mrb, self, MRB_IVSYM(unit_num)));
uint32_t timeout_us = (uint32_t)timeout_ms * 1000;
uint8_t *rxbuf = (uint8_t*)mrb_malloc(mrb, len);
int ret;
if (argc > 0) {
/* write-then-read (repeated START) */
uint8_t sbuf[STACK_BUF_SIZE];
size_t wlen;
mrb_bool need_free;
uint8_t *wbuf = i2c_build_buf(mrb, args, argc, &wlen, sbuf, &need_free);
ret = mrb_i2c_write_read((int)unit, (uint8_t)addr,
wbuf, wlen, rxbuf, (size_t)len, timeout_us);
if (need_free) mrb_free(mrb, wbuf);
}
else {
ret = mrb_i2c_read((int)unit, (uint8_t)addr, rxbuf, (size_t)len, timeout_us);
}
if (ret < 0) {
mrb_free(mrb, rxbuf);
mrb_raise(mrb, E_IO_ERROR, "I2C read failed");
}
mrb_value str = mrb_str_new(mrb, (const char*)rxbuf, ret);
mrb_free(mrb, rxbuf);
return str;
}
static mrb_value
mrb_i2c_m_init(mrb_state *mrb, mrb_value self)
{
const char *unit;
mrb_int freq, sda, scl;
mrb_get_args(mrb, "ziii", &unit, &freq, &sda, &scl);
int num = mrb_i2c_unit_name_to_num(unit);
if (num < 0) {
mrb_raisef(mrb, E_ARGUMENT_ERROR, "unknown I2C unit: %s", unit);
}
mrb_i2c_status st = mrb_i2c_init(num, (uint32_t)freq, (int8_t)sda, (int8_t)scl);
if (st != MRB_I2C_OK) {
mrb_raise(mrb, E_IO_ERROR, "I2C init failed");
}
return mrb_fixnum_value(num);
}
void
mrb_hw_i2c_gem_init(mrb_state *mrb)
{
struct RClass *cls = mrb_define_class_id(mrb, MRB_SYM(I2C), mrb->object_class);
mrb_define_method_id(mrb, cls, MRB_SYM(__init), mrb_i2c_m_init, MRB_ARGS_REQ(4));
mrb_define_method_id(mrb, cls, MRB_SYM(write), mrb_i2c_m_write, MRB_ARGS_REQ(1)|MRB_ARGS_REST()|MRB_ARGS_KEY(1, 0));
mrb_define_method_id(mrb, cls, MRB_SYM(read), mrb_i2c_m_read, MRB_ARGS_REQ(2)|MRB_ARGS_REST()|MRB_ARGS_KEY(1, 0));
}
void
mrb_hw_i2c_gem_final(mrb_state *mrb)
{
}
+7
View File
@@ -0,0 +1,7 @@
MRuby::Gem::Specification.new('hw-rp2040-i2c') do |spec|
spec.license = 'MIT'
spec.author = 'mruby developers'
spec.summary = 'I2C HAL for RP2040'
spec.add_dependency 'hw-i2c'
end
+69
View File
@@ -0,0 +1,69 @@
#include <string.h>
#include "pico/stdlib.h"
#include "hardware/i2c.h"
#include <mruby/i2c.h>
#define UNIT_SELECT(u) \
i2c_inst_t *inst; \
switch (u) { \
case 0: inst = i2c0; break; \
case 1: inst = i2c1; break; \
default: return MRB_I2C_ERROR_UNIT; \
}
int
mrb_i2c_unit_name_to_num(const char *name)
{
if (strcmp(name, "RP2040_I2C0") == 0) return 0;
if (strcmp(name, "RP2040_I2C1") == 0) return 1;
return MRB_I2C_ERROR_UNIT;
}
mrb_i2c_status
mrb_i2c_init(int unit, uint32_t freq, int8_t sda, int8_t scl)
{
UNIT_SELECT(unit);
i2c_init(inst, freq);
if (sda < 0) sda = PICO_DEFAULT_I2C_SDA_PIN;
if (scl < 0) scl = PICO_DEFAULT_I2C_SCL_PIN;
gpio_set_function(sda, GPIO_FUNC_I2C);
gpio_set_function(scl, GPIO_FUNC_I2C);
gpio_pull_up(sda);
gpio_pull_up(scl);
return MRB_I2C_OK;
}
int
mrb_i2c_read(int unit, uint8_t addr, uint8_t *dst, size_t len,
uint32_t timeout_us)
{
UNIT_SELECT(unit);
return i2c_read_timeout_us(inst, addr, dst, len, false, timeout_us);
}
int
mrb_i2c_write(int unit, uint8_t addr, const uint8_t *src, size_t len,
uint32_t timeout_us)
{
UNIT_SELECT(unit);
return i2c_write_timeout_us(inst, addr, src, len, false, timeout_us);
}
int
mrb_i2c_write_read(int unit, uint8_t addr, const uint8_t *src, size_t wlen,
uint8_t *dst, size_t rlen, uint32_t timeout_us)
{
UNIT_SELECT(unit);
/* write with nostop=true (no STOP, keeps bus for repeated START) */
int ret = i2c_write_timeout_us(inst, addr, src, wlen, true, timeout_us);
if (ret < 0) return ret;
/* read with nostop=false (STOP after read) */
return i2c_read_timeout_us(inst, addr, dst, rlen, false, timeout_us);
}
#include <mruby.h>
void mrb_hw_rp2040_i2c_gem_init(mrb_state *mrb) {}
void mrb_hw_rp2040_i2c_gem_final(mrb_state *mrb) {}