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)”
ModuleComponentPurpose
adclua_driver_adcADC sampling
gpiolua_driver_gpioGPIO reads/writes and direction
i2clua_driver_i2cI2C bus scan and per-device read/write
ssd1306lua_driver_i2cPure-Lua SSD1306 I2C OLED driver
lib_si12t_touchlua_driver_i2cPure-Lua Si12T I2C capacitive touch driver
mcpwmlua_driver_mcpwmGeneric PWM output (frequency/duty control)
pcntlua_driver_pcntPCNT pulse counter / encoder reading
touchlua_driver_touchCapacitive touch key reads
uartlua_driver_uartUART serial I/O (polling read/write)
ModuleComponentPurpose
audiolua_module_audioPCM/WAV playback/recording and spectrum analysis
ble_hidlua_module_ble_hidBLE HID composite peripheral (media control, keyboard, mouse)
board_managerlua_module_board_managerBoard initialization and peripheral handle lookup
buttonlua_module_buttonButton events and callbacks
capabilitylua_module_call_capabilityCall registered Capabilities directly from Lua
cameralua_module_cameraStill capture / streaming hooks
delaylua_module_delaydelay.delay_ms(n) helpers
dhtlua_module_dhtDHT-family temperature and humidity reads
displaylua_module_displayLCD drawing (text, primitives, JPEG/PNG)
environmental_sensorlua_module_environmental_sensorBME690 temperature, humidity, pressure, and gas data
event_publisherlua_module_event_publisherPublish events from Lua into the Event Router
http_serverlua_module_http_serverPublish static files and HTTP callbacks on the existing HTTP server
imagelua_module_imageGeneric image frame type, format conversion, resizing, and file I/O
jsonlua_module_jsonJSON encode/decode helper library
lib_fuel_gaugelua_module_fuel_gaugePure-Lua battery fuel-gauge library for BQ27220/MAX17048 and similar chips
imulua_module_imuIMU accelerometer and gyroscope reads
irlua_module_irIR receive, learn, and transmit
knoblua_module_knobRotary encoder / knob events
lcdlua_module_lcdLCD panel initialization, backlight, and display helpers
lcd_touchlua_module_lcd_touchTouch coordinates
led_striplua_module_led_stripAddressable strips such as WS2812
lvgllua_module_lvglLVGL graphics binding with full UI widgets and event system
magnetometerlua_module_magnetometerMagnetometer / compass reads
motion_detectlua_module_visionMotion detection based on image.frame
scilua_module_sciDFRobot SCI acquisition module (DFR0999) I2C reading
storagelua_module_storageFilesystem operations
systemlua_module_systemTime, uptime, IP, memory, heap, task stack, and Wi-Fi status
threadlua_module_threadLua task management, named synchronization queues/semaphores/event groups
arg_schemalua_module_systemPure-Lua argument normalization helper

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 body calls cap_lua_register_module to bind the Lua module name to its luaopen_* entry:

// lua_module_display/src/lua_module_display.c
esp_err_t lua_module_display_register(void)
{
    return cap_lua_register_module("display", luaopen_display);
}

All modules must register before cap_lua_register_group()—later calls fail because the runtime locks registration.

// app_claw.c or equivalent init
#include "lua_module_display.h"
#include "lua_driver_gpio.h"
#include "lua_module_delay.h"
#include "cap_lua.h"

void app_lua_modules_init(void)
{
    lua_module_display_register();
    lua_driver_gpio_register();
    lua_module_delay_register();
    // …other modules…

    cap_lua_register_group("/fatfs/scripts");  // pass script base dir; no more registration after lock
}

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 pathSync output
README.md<component_name>.md in the Lua module docs output directory
test/foo.luatest/foo.lua in the built-in Lua output directory
lib/foo.lualib/foo.lua in the built-in Lua output directory
lib/foo.mdlib/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.ccomponents/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 APIDescription
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.

After display.init(...) succeeds in lua_module_display, Lua automatically acquires foreground display ownership; ownership is released on display.deinit() or script-exit cleanup to avoid conflicts with other display tasks.

Additionally, in display-HAL re-creation scenarios, the runtime cleans up stale swap-buffer and display-callback state to reduce cross-script display resource leak risks (especially when switching with lua_run_script_async exclusive:"display" + replace:true).

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
})