Skip to content

Lua extension modules (`lua_module_*`)

lua_module_* components are ESP-Claw’s bridge from hardware to Lua. Each module is a standard Lua C library (lua_CFunction style), registered through cap_lua_register_module, then loaded in scripts via require("module_name").

ModuleComponentPurpose
displaylua_module_displayLCD drawing (text, primitives, JPEG/PNG)
gpiolua_module_gpioGPIO reads/writes and direction
buttonlua_module_buttonButton events + callbacks
led_striplua_module_led_stripAddressable strips such as WS2812
audiolua_module_audioPCM/WAV playback/recording and spectrum analysis
cameralua_module_cameraStill capture / streaming hooks
lcd_touchlua_module_lcd_touchTouch coordinates
delaylua_module_delaydelay.delay_ms(n) helpers
storagelua_module_storageFilesystem operations
esp_heaplua_module_esp_heapHeap introspection
systemlua_module_systemTime, uptime, IP, memory, and Wi‑Fi status
mcpwmlua_module_mcpwmGeneric PWM output (frequency/duty control)
event_publisherlua_module_event_publisherPublish events from Lua into the Event Router
board_managerlua_module_board_managerBoard init + peripheral handles

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

// lua_module_display/include/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_module_gpio.h"
#include "lua_module_delay.h"
#include "cap_lua.h"

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

    cap_lua_register_group();  // lock—no more registration afterward
}

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

components/lua_modules/lua_module_myled/
├── CMakeLists.txt
├── include/
│   └── lua_module_myled.h
└── lua_module_myled.c
# CMakeLists.txt
idf_component_register(
    SRCS "lua_module_myled.c"
    INCLUDE_DIRS "include"
    REQUIRES cap_lua driver
)

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
// 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);
}
// include/lua_module_myled.h
#pragma once
#include "esp_err.h"

esp_err_t lua_module_myled_register(void);
local myled = require("myled")

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

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

lua_module_myled/
└── skills/
    ├── lua_module_myled.md   # Skill Markdown
    └── skills_list.json      # metadata manifest

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

{
  "skills": [
    {
      "id": "lua_module_myled",
      "file": "lua_module_myled.md",
      "summary": "How to control a single LED from Lua using the myled module.",
      "cap_groups": ["cap_lua"]
    }
  ]
}

Activating the Skill exposes the cap_lua tools (lua_run_script, lua_write_script, …) while injecting the module guide so the LLM can author myled scripts confidently.

For API-doc expectations, see the Skills reference.

Source: lua_module_display.c

lua_module_display is the richest module—a full LCD toolkit and the best reference for advanced designs.

The module never talks to vendor drivers directly; everything routes through display_hal.h:

// include/display_hal.h (board-specific implementation)
esp_err_t display_hal_create(esp_lcd_panel_handle_t, esp_lcd_panel_io_handle_t, int w, int h);
esp_err_t display_hal_draw_text(int x, int y, const char *text, uint8_t font_size, ...);
esp_err_t display_hal_draw_jpeg(int x, int y, const uint8_t *data, size_t len, ...);
// …more prototypes…

Lua code only parses arguments and calls HAL hooks; board code supplies HAL, so one Lua API can target many LCD controllers.

Shared helpers keep errors consistent:

static int lua_display_check_integer_arg(lua_State *L, int index, const char *name)
{
    if (!lua_isinteger(L, index)) {
        return luaL_error(L, "display %s must be an integer", name);
    }
    return (int)lua_tointeger(L, index);
}

// Pack three ints into RGB565
static uint16_t lua_display_color(lua_State *L, int start_index)
{
    int r = lua_display_check_integer_arg(L, start_index,     "color component");
    int g = lua_display_check_integer_arg(L, start_index + 1, "color component");
    int b = lua_display_check_integer_arg(L, start_index + 2, "color component");
    return (uint16_t)(((r & 0xF8) << 8) | ((g & 0xFC) << 3) | ((b & 0xF8) >> 3));
}

Text helpers accept optional tables, field by field:

static void lua_display_parse_text_options(lua_State *L, int index,
                                            uint8_t *text_r, uint8_t *text_g, uint8_t *text_b,
                                            uint8_t *font_size, ...)
{
    if (lua_isnoneornil(L, index)) return;  // optional argument
    luaL_checktype(L, index, LUA_TTABLE);

    lua_getfield(L, index, "r");   // read options.r
    if (!lua_isnil(L, -1) && text_r) {
        *text_r = (uint8_t)lua_display_check_integer_arg(L, -1, "text color component");
    }
    lua_pop(L, 1);
    // …same pattern for g, b, font_size, align, valign…
}

Lua usage:

local d = require("display")
d.begin_frame({clear=true, r=0, g=0, b=0})
d.draw_text(10, 20, "Hello!", {
    r=255, g=255, b=0,    -- yellow text
    font_size=24,
    align="center"
})
d.present()

Built-in media helpers:

APIFormatBehavior
draw_jpeg_fileJPEGDecode from disk via HAL
draw_png_filePNGlibpng → RGB565 via HAL
draw_jpeg_file_scaledJPEGHW scaler (scale_w/h multiple of 8)
draw_jpeg_file_fitJPEGLetterbox to a region
draw_jpeg_file_cropJPEGCrop window inside JPEG

PNG decoding stays in C (simpler lifetime story). RGBA→RGB565 premultiplies against black:

uint8_t alpha = src[3];
uint8_t r = (uint8_t)((src[0] * alpha + 127) / 255);
// …

In addition to file-based image drawing, lua_module_display supports drawing RGB565 data directly from a memory buffer, which is suitable for real-time scenarios like camera preview:

FunctionDescription
draw_rgb565_cropCrop a specified area from an RGB565 buffer and display it
draw_rgb565_scaledScale an RGB565 buffer to a specified size and display it
draw_rgb565_fitAdapt an RGB565 buffer proportionally to the target area and display it

The data parameter can be a Lua string or lightuserdata (e.g., camera.frame:ptr()). The number of bytes must be src_width * src_height * 2. For a typical usage, see the camera preview example at camera_preview_demo.lua.

Double buffering uses begin_frame / present / end_frame to avoid tearing:

d.begin_frame({clear=true})  -- clear back buffer
-- …all drawing…
d.present()                  -- swap / flush once
d.end_frame()

Image paths must be absolute (/…), end with .jpg, .jpeg, or .png, and must not contain ...

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