Skip to content

Lua extension modules (`lua_module_*` / `lua_driver_*`)

lua_module_* and lua_driver_* components are ESP-Claw’s bridge from hardware to Lua. lua_driver_* wraps low-level peripheral drivers (ADC, GPIO, I2C, MCPWM, Touch, UART), while lua_module_* covers higher-level modules. Each component is a standard Lua C library (lua_CFunction style), registered through cap_lua_register_module, then loaded in scripts via require("module_name"). Pure Lua modules may provide only managed resources such as lib/, test/, and README.md.

lua_driver_* (hardware peripheral drivers)

Section titled “lua_driver_* (hardware peripheral drivers)”
Module Component Purpose
adc lua_driver_adc ADC sampling
gpio lua_driver_gpio GPIO reads/writes and direction
i2c lua_driver_i2c I2C bus scan and per-device read/write
ssd1306 lua_driver_i2c Pure-Lua SSD1306 I2C OLED driver
lib_si12t_touch lua_driver_i2c Pure-Lua Si12T I2C capacitive touch driver
mcpwm lua_driver_mcpwm Generic PWM output (frequency/duty control)
pcnt lua_driver_pcnt PCNT pulse counter / encoder reading
touch lua_driver_touch Capacitive touch key reads
uart lua_driver_uart UART serial I/O (polling read/write)
Module Component Purpose
audio lua_module_audio PCM/WAV playback/recording and spectrum analysis
ble_hid lua_module_ble_hid BLE HID composite peripheral (media control, keyboard, mouse)
board_manager lua_module_board_manager Board initialization and peripheral handle lookup
button lua_module_button Button events and callbacks
capability lua_module_call_capability Call registered Capabilities directly from Lua
camera lua_module_camera Still capture / streaming hooks
delay lua_module_delay delay.delay_ms(n) helpers
dht lua_module_dht DHT-family temperature and humidity reads
display lua_module_display LCD drawing and touch input
environmental_sensor lua_module_environmental_sensor BME690 temperature, humidity, pressure, and gas data
event_publisher lua_module_event_publisher Publish events from Lua into the Event Router
http_server lua_module_http_server Publish static files and HTTP callbacks on the existing HTTP server
image lua_module_image Generic image frame type, format conversion, resizing, and file I/O
json lua_module_json JSON encode/decode helper library
lib_fuel_gauge lua_module_fuel_gauge Pure-Lua battery fuel-gauge library for BQ27220/MAX17048 and similar chips
imu lua_module_imu IMU accelerometer and gyroscope reads
ir lua_module_ir IR receive, learn, and transmit
knob lua_module_knob Rotary encoder / knob events
lcd lua_module_lcd Optional LCD panel initialization, reset, and metadata; not used by display
led_strip lua_module_led_strip Addressable strips such as WS2812
lvgl lua_module_lvgl LVGL graphics binding with full UI widgets and event system
magnetometer lua_module_magnetometer Magnetometer / compass reads
motion_detect lua_module_vision Motion detection based on image.frame
sci lua_module_sci DFRobot SCI acquisition module (DFR0999) I2C reading
storage lua_module_storage Filesystem operations
system lua_module_system Time, uptime, IP, memory, heap, task stack, and Wi-Fi status
thread lua_module_thread Lua task management, named synchronization queues/semaphores/event groups
arg_schema lua_module_system Pure-Lua argument normalization helper

Hardware objects claim resources through a shared registry: opening fails when a resource is unregistered or already held exclusively. Board code and providers register resources; scripts open, use, and close objects. i2c.new(...) creates a bus, while i2c.borrow(0) borrows the initialized board bus. Device addresses remain exclusive, so borrowing cannot override board devices.

The constructors lcd.new(...), environmental_sensor.new(...), magnetometer.new(...), and sci.new(...) currently return an explicit error because their resource ownership integration is incomplete, even when their components are enabled. This does not affect access to the built-in screen through display.open().

On script exit, hardware stops before its claims are released. If hardware cleanup fails, the runtime retains the Lua state and claims, then retries when a later job starts. This prevents other jobs from taking over hardware that is still active. Use the Console command hwmap to inspect ownership.

The runtime keeps only traceback and getinfo in the debug library; interfaces that modify the registry, metatables, closures, or execution hooks are unavailable.

Each lua_module_* / lua_driver_* ships exactly one registrar named lua_module_<name>_register() or lua_driver_<name>_register():

// lua_module_display/src/lua_module_display.h
esp_err_t lua_module_display_register(void);

The registrar binds the Lua module name to its luaopen_* entry and may perform module-specific setup. lua_module_display_register() also registers script-exit cleanup.

Register modules before the Lua capability group initializes. Group initialization locks module registration; later calls fail.

// Minimal app registration example
#include "lua_module_display.h"
#include "cap_lua.h"

esp_err_t register_display_lua_capability(void)
{
    esp_err_t err = lua_module_display_register();
    if (err != ESP_OK) return err;
    return cap_lua_register_group();
}

Below is a minimal myled module (single GPIO LED) end-to-end.

components/lua_modules/lua_module_myled/
├── CMakeLists.txt
├── README.md
├── test/
│   └── myled_smoke.lua
├── lib/
│   ├── lib_myled.md
│   └── lib_myled.lua
└── src/
    ├── lua_module_myled.c
    └── lua_module_myled.h

CMakeLists.txt and README.md are required. test/, lib/, and src/ are optional.

# CMakeLists.txt
idf_component_register(
    SRCS "src/lua_module_myled.c"
    INCLUDE_DIRS "src"
    REQUIRES cap_lua driver
)

Pure Lua modules may use an empty idf_component_register(). C-backed modules must list their C source files and include directories.

README.md is the required module-level API document. During build it is synced into the Lua module reference docs. It should describe:

  • What the module does and which Lua APIs or reusable Lua libraries it provides.
  • How to call each API, including arguments, return values, error behavior, and cleanup requirements.
  • Hardware resource ownership, blocking behavior, concurrency limits, and safe GPIO guidance when relevant.

README.md should document only APIs that already exist. Avoid describing planned or unimplemented functions; keep examples short and directly runnable when possible.

Every binding uses int fn(lua_State *L):

  • Read arguments with luaL_check* helpers
  • Perform the hardware/service work
  • Push return values and return the result count
// src/lua_module_myled.c
#include "lua.h"
#include "lauxlib.h"
#include "cap_lua.h"
#include "driver/gpio.h"

#define MYLED_GPIO GPIO_NUM_2

// Lua: myled.set(on)
static int myled_set(lua_State *L)
{
    // 1. read stack args (bool)
    int on = lua_toboolean(L, 1);

    // 2. drive hardware
    gpio_set_level(MYLED_GPIO, on ? 1 : 0);

    // 3. no Lua return values
    return 0;
}

// Lua: myled.get() -> bool
static int myled_get(lua_State *L)
{
    int level = gpio_get_level(MYLED_GPIO);
    lua_pushboolean(L, level);
    return 1;  // one return value
}

// Module entry: publish functions on a table
int luaopen_myled(lua_State *L)
{
    // GPIO setup (normally centralized in board_manager)
    gpio_config_t cfg = {
        .pin_bit_mask = (1ULL << MYLED_GPIO),
        .mode = GPIO_MODE_OUTPUT,
    };
    gpio_config(&cfg);

    // build module table
    lua_newtable(L);

    lua_pushcfunction(L, myled_set);
    lua_setfield(L, -2, "set");

    lua_pushcfunction(L, myled_get);
    lua_setfield(L, -2, "get");

    return 1;  // return the table
}

esp_err_t lua_module_myled_register(void)
{
    return cap_lua_register_module("myled", luaopen_myled);
}
// src/lua_module_myled.h
#pragma once
#include "esp_err.h"

esp_err_t lua_module_myled_register(void);

test/ stores model-readable reference scripts for module validation, hardware validation, demos, and implementation patterns. Hardware-related test scripts should be self-contained and include required cleanup and resource release.

For example, test/myled_smoke.lua can directly show how scripts load and call the module:

local myled = require("myled")
local delay = require("delay")

myled.set(true)   -- on
delay.delay_ms(500)     -- needs lua_module_delay
myled.set(false)  -- off

lib/ stores reusable Lua libraries. lib/abc.lua is an implementation library, not a directly runnable program. Public library functions must be documented in the same-name lib/abc.md. Every synced lib/*.lua must have a same-name Markdown document.

Just like cap_* modules, each lua_module_* / lua_driver_* should ship a Skill so the LLM knows how to call it from generated scripts.

The build system collects managed lua_module_* resources and syncs them into the application filesystem image. Output roots are application-specific; in edge_agent, built-in Lua resources usually go to /fatfs/scripts/builtin. Module docs go to /fatfs/scripts/docs.

Source path Sync output
README.md <component_name>.md in the Lua module docs output directory
test/foo.lua test/foo.lua in the built-in Lua output directory
lib/foo.lua lib/foo.lua in the built-in Lua output directory
lib/foo.md lib/foo.md in the built-in Lua output directory

Sync rules:

  • README.md sync applies only to components named lua_module_*.
  • lib/ and test/ preserve their relative paths under those directories.
  • All synced script and library output paths must be globally unique.
  • Root-level .lua files are not managed sync outputs; modules should not maintain flat root-level Lua files.
  • Lua module directories must be named lua_module_xx.

Source: lua_module_gpio.c components/lua_modules/lua_module_gpio/src/lua_module_gpio.c

lua_module_gpio is a small but complete C-backed Lua module and a good reference for simple hardware bindings. It exposes only three Lua APIs: configure direction, write output level, and read input level.

lua_module_gpio follows the standard lua_module_* layout:

lua_module_gpio/
├── CMakeLists.txt
├── README.md
└── src/
    ├── lua_module_gpio.c
    └── lua_module_gpio.h

README.md documents script-side APIs, while src/ stores the C implementation and registration header:

// src/lua_module_gpio.h
int luaopen_gpio(lua_State *L);
esp_err_t lua_module_gpio_register(void);

Unlike cap_* Skills, lua_module_* / lua_driver_* Skills do not bind a dedicated capability group (there is no lua_module group). They attach to cap_lua instead.

Lua API Description
gpio.set_direction(pin, mode) Configure GPIO direction
gpio.set_level(pin, level) Set output level; non-zero means high
gpio.get_level(pin) Read current GPIO level and return an integer

mode supports "input", "output", "input_output", "output_od", "input_output_od", and "disable".

The registration function only binds Lua module name gpio to luaopen_gpio in cap_lua:

esp_err_t lua_module_gpio_register(void)
{
    return cap_lua_register_module("gpio", luaopen_gpio);
}

After the application calls lua_module_gpio_register() during init, scripts can use require("gpio") to get the module table.

luaopen_gpio creates a Lua table and attaches C functions to public fields:

int luaopen_gpio(lua_State *L)
{
    lua_newtable(L);

    lua_pushcfunction(L, lua_module_gpio_set_direction);
    lua_setfield(L, -2, "set_direction");
    lua_pushcfunction(L, lua_module_gpio_set_level);
    lua_setfield(L, -2, "set_level");
    lua_pushcfunction(L, lua_module_gpio_get_level);
    lua_setfield(L, -2, "get_level");

    return 1;
}

This pattern fits most simple modules: call lua_newtable(), attach functions with lua_setfield(), then return one table.

The GPIO module uses luaL_checkinteger / luaL_checkstring for argument validation. These helpers raise Lua errors directly when arguments are missing or have the wrong type, so C code does not continue with invalid input.

static int lua_module_gpio_set_level(lua_State *L)
{
    gpio_num_t pin = (gpio_num_t)luaL_checkinteger(L, 1);
    uint32_t level = (uint32_t)luaL_checkinteger(L, 2);

    if (gpio_set_level(pin, level ? 1 : 0) != ESP_OK) {
        return luaL_error(L, "gpio_set_level failed");
    }
    return 0;
}

When an underlying ESP-IDF call fails, the module uses luaL_error to return the failure to the Lua runtime. This makes script execution fail with a clear reason visible to the caller.

Activating the Skill exposes the Lua run tools (lua_run_script, lua_run_script_async, …), while the Skill document is injected into conversation history as the activate_skill tool result so the LLM can author myled scripts confidently. Script files are created and inspected with file tools such as write_file, read_file, and list_dir.

set_direction accepts mode as a string, then centralizes conversion to ESP-IDF gpio_mode_t:

static gpio_mode_t lua_module_gpio_mode_from_string(const char *mode)
{
    if (!mode || strcmp(mode, "input") == 0) {
        return GPIO_MODE_INPUT;
    }
    if (strcmp(mode, "output") == 0) {
        return GPIO_MODE_OUTPUT;
    }
    if (strcmp(mode, "input_output") == 0) {
        return GPIO_MODE_INPUT_OUTPUT;
    }
    if (strcmp(mode, "output_od") == 0) {
        return GPIO_MODE_OUTPUT_OD;
    }
    if (strcmp(mode, "input_output_od") == 0) {
        return GPIO_MODE_INPUT_OUTPUT_OD;
    }
    if (strcmp(mode, "disable") == 0) {
        return GPIO_MODE_DISABLE;
    }
    return GPIO_MODE_DISABLE;
}

set_direction then distinguishes valid "disable" from an unknown string:

if (mode == GPIO_MODE_DISABLE && strcmp(mode_str, "disable") != 0) {
    return luaL_error(L, "invalid gpio mode: %s", mode_str);
}

The GPIO example under test/ can show the minimal call flow directly:

local gpio = require("gpio")

gpio.set_direction(2, "output")
gpio.set_level(2, 1)
local level = gpio.get_level(2)
print("GPIO2 level:", level)

Hardware test scripts should choose GPIOs according to the actual board wiring. Avoid boot strapping pins, Flash/PSRAM pins, or IOs already occupied by other peripherals.

cap_lua overview Runtime setup, script management, async jobs
Dataflow and automation Event Router `run_script` actions

The display module has a dedicated ownership-management (“arbitration”) mechanism, ensuring Lua scripts can exclusively use display resources and avoid conflicts with other tasks.

display.open(...) acquires exclusive ownership of the built-in screen and returns a screen object. screen:close(), <close>, or script-exit cleanup releases ownership. Draw between screen:begin() and screen:present(); opening an already-open screen fails.

For long-running scenes, lua_run_script_async can use exclusive:"display" and replace:true to stop the previous display job before starting another.

event_publisher: publish events to Event Router

Section titled “event_publisher: publish events to Event Router”

publish_message supports two forms:

  1. String form: simpler, but carries less information
local ep = require("event_publisher")
ep.publish_message("Button pressed!")
  1. Message-object form: carries more information, but you need to build the message object manually
local ep = require("event_publisher")
ep.publish_message({
  source_cap = "lua_script", -- required
  channel = args.channel, -- optional; if missing, runtime tries to backfill from global args.channel
  chat_id = args.chat_id, -- optional; if missing, runtime tries to backfill from global args.chat_id
  text = "Button pressed!", -- required
})