跳转到内容

Lua 扩展模块(lua_module_* / lua_driver_*)

lua_module_*lua_driver_* 是 ESP-Claw 中将硬件外设能力暴露给 Lua 脚本的扩展机制。lua_driver_* 封装底层外设驱动(ADC、GPIO、I2C、MCPWM、Touch、UART),lua_module_* 涵盖更高层的功能模块。每个组件本质上是一个标准 Lua C 模块(lua_CFunction 格式),通过 cap_lua_register_module 注册后,在 Lua 脚本中可以用 require("module_name") 加载。 纯 Lua 模块也可以只提供 lib/test/README.md 等。

模块名组件目录说明
adclua_driver_adcADC 采样
gpiolua_driver_gpioGPIO 读写、方向配置
i2clua_driver_i2cI2C 总线扫描与设备读写
ssd1306lua_driver_i2c纯 Lua SSD1306 I2C OLED 驱动
lib_si12t_touchlua_driver_i2c纯 Lua Si12T I2C 电容触摸驱动
mcpwmlua_driver_mcpwm通用 PWM 输出(频率/占空比控制)
pcntlua_driver_pcntPCNT 脉冲计数器/编码器读取
touchlua_driver_touch电容触摸按键数据读取
uartlua_driver_uartUART 串口收发(polling 读写)
模块名组件目录说明
audiolua_module_audio音频播放/录制(PCM/WAV)与频谱分析
ble_hidlua_module_ble_hidBLE HID 复合外设(媒体控制、键盘、鼠标)
board_managerlua_module_board_manager板级初始化与外设句柄获取
buttonlua_module_button按键事件注册与回调
capabilitylua_module_call_capability从 Lua 脚本直接调用已注册 Capability
cameralua_module_camera摄像头拍照与流式采集
delaylua_module_delaydelay.delay_ms(n) 毫秒级延时
dhtlua_module_dhtDHT 系列温湿度传感器读取
displaylua_module_displayLCD 屏幕绘图(文字、图形、JPEG/PNG)
environmental_sensorlua_module_environmental_sensorBME690 温度、湿度、气压与气体数据读取
event_publisherlua_module_event_publisher从 Lua 脚本向 Event Router 发布事件
http_serverlua_module_http_server在现有 HTTP 服务器上发布静态文件与 HTTP 回调
imagelua_module_image通用图像帧类型、格式转换、缩放与文件读写
jsonlua_module_jsonJSON 编码/解码辅助库
lib_fuel_gaugelua_module_fuel_gauge纯 Lua 电池电量计库,支持 BQ27220/MAX17048 等
imulua_module_imuIMU 加速度计与陀螺仪数据读取
irlua_module_ir红外接收、学习与发送
knoblua_module_knob旋钮/旋转编码器事件读取
lcdlua_module_lcdLCD 面板初始化、背光与显示辅助控制
lcd_touchlua_module_lcd_touch触摸屏坐标读取
led_striplua_module_led_stripWS2812 等可寻址 LED 灯带控制
lvgllua_module_lvglLVGL 图形库绑定,支持完整 UI 控件与事件系统
magnetometerlua_module_magnetometer磁力计/指南针数据读取
motion_detectlua_module_vision基于 image.frame 的运动检测
scilua_module_sciDFRobot SCI 采集模块(DFR0999)I2C 读取
storagelua_module_storage文件系统操作
systemlua_module_system时间、运行时长、IP、内存、堆、任务栈与 Wi-Fi 状态查询
threadlua_module_threadLua 任务管理、命名同步队列/信号量/事件组
arg_schemalua_module_system纯 Lua 参数归一化辅助库

每个 lua_module_* / lua_driver_* 组件对外只暴露一个注册函数,命名规则为 lua_module_<name>_register()lua_driver_<name>_register()

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

注册函数内部调用 cap_lua_register_module,将模块名与 luaopen_* 函数关联:

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

注意:所有模块必须在 cap_lua_register_group() 之前注册,之后注册会被拒绝(运行时锁定)。

// app_claw.c 或类似初始化文件
#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();
    // ... 其他模块 ...

    cap_lua_register_group("/fatfs/scripts");  // 传入脚本基目录,锁定后不能再注册模块
}

以下展示实现一个简单的 myled 模块(控制单个 LED)的完整流程:

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.txtREADME.md 是必需文件;test/lib/src/ 均为可选目录。

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

纯 Lua 模块可以使用空的 idf_component_register();C-backed 模块必须列出 C 源文件和 include 目录。

README.md 是必需的模块级 API 文档,构建时会同步为 Lua 模块参考文档,它应描述:

  • 模块做什么,提供哪些 Lua API 或可复用 Lua 库。
  • 每个 API 的调用方式、参数、返回值、错误行为和清理要求。
  • 硬件资源所有权、阻塞行为、并发限制,以及安全 GPIO 建议(如适用)。

README.md 只应记录已经实现的 API,避免描述规划中或不存在的函数;示例应短小并尽量可直接运行。

每个 Lua C 函数遵循统一签名 int func(lua_State *L)

  • 通过 luaL_check* 系列函数从 Lua 栈获取参数
  • 执行实际操作(调用 ESP-IDF 驱动等)
  • 将返回值 push 到栈,return 返回值数量
// 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. 从栈获取参数(bool)
    int on = lua_toboolean(L, 1);

    // 2. 执行操作
    gpio_set_level(MYLED_GPIO, on ? 1 : 0);

    // 3. 无返回值,push 0 个值
    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;  // 返回 1 个值
}

// 模块入口:注册所有函数到 Lua table
int luaopen_myled(lua_State *L)
{
    // 初始化 GPIO(实际项目中通常由 board_manager 管理)
    gpio_config_t cfg = {
        .pin_bit_mask = (1ULL << MYLED_GPIO),
        .mode = GPIO_MODE_OUTPUT,
    };
    gpio_config(&cfg);

    // 创建模块 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;  // 返回 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/ 用于保存模型可读的参考脚本,可用于模块验证、硬件验证、演示和实现模式示例。 硬件相关测试脚本应自包含,并包含必要的清理逻辑和资源释放。

例如 test/myled_smoke.lua 可以直接展示脚本侧如何加载和调用模块:

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

myled.set(true)   -- 点亮
delay.delay_ms(500)     -- 需要 lua_module_delay
myled.set(false)  -- 熄灭

lib/ 用于保存可复用 Lua 库。lib/abc.lua 是实现库,不是可直接运行的程序。 公开库函数必须在同名 lib/abc.md 中说明。每个被同步的 lib/*.lua 都必须有同名 Markdown 文档。

lua_module_* / lua_driver_*cap_* 一样,强烈建议为每个组件提供配套的 Skill 文档,告诉 LLM 如何在 Lua 脚本中正确使用该模块。

构建系统会收集 lua_module_* 的受管资源并同步到应用文件系统镜像。 输出根目录由应用配置决定;在 edge_agent 中,内置 Lua 资源通常进入 /fatfs/scripts/builtin。 模块文档进入 /fatfs/scripts/docs

源路径同步输出
README.mdLua 模块文档输出目录中的 <component_name>.md
test/foo.lua内置 Lua 输出目录中的 test/foo.lua
lib/foo.lua内置 Lua 输出目录中的 lib/foo.lua
lib/foo.md内置 Lua 输出目录中的 lib/foo.md

同步规则:

  • README.md 同步只适用于组件名为 lua_module_* 的组件。
  • lib/test/ 会保留各自目录内的相对路径。
  • 所有同步后的脚本和库输出路径必须全局唯一。
  • 根目录下的 .lua 文件不是受管同步输出,模块不应维护扁平的根级 Lua 文件。
  • Lua 模块目录必须命名为 lua_module_xx

典型外设模块深度分析:lua_module_gpio

Section titled “典型外设模块深度分析:lua_module_gpio”

源码:lua_module_gpio.ccomponents/lua_modules/lua_module_gpio/src/lua_module_gpio.c

lua_module_gpio 是一个小而完整的 C-backed Lua 模块,适合作为实现简单硬件绑定的参考。 它只暴露三个 Lua API:设置方向、写输出电平、读输入电平。

lua_module_gpio 遵循标准 lua_module_* 目录布局:

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

README.md 记录脚本侧 API,src/ 保存 C 实现与注册头文件:

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

cap_* 的 Skill 不同,lua_module_* / lua_driver_* 的 Skill 绑定的 cap_groups 不是自己的 Group(lua_module 没有独立的 Capability Group),而是绑定到 cap_lua

Lua API说明
gpio.set_direction(pin, mode)设置 GPIO 方向
gpio.set_level(pin, level)设置输出电平,非 0 视为高电平
gpio.get_level(pin)读取 GPIO 当前电平,返回整数

mode 支持 "input""output""input_output""output_od""input_output_od""disable"

模块注册函数只负责把 Lua 模块名 gpioluaopen_gpio 绑定到 cap_lua

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

应用初始化阶段调用 lua_module_gpio_register() 后,脚本即可通过 require("gpio") 获取模块 table。

luaopen_gpio 创建一个 Lua table,并把 C 函数挂到公开字段上:

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

这个模式适合大多数简单模块:先 lua_newtable(),再逐个 lua_setfield(),最后返回 1 个 table。

GPIO 模块使用 luaL_checkinteger / luaL_checkstring 做参数校验。 这些函数会在参数缺失或类型错误时直接抛出 Lua error,避免 C 层继续处理非法输入。

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

当底层 ESP-IDF 调用失败时,模块用 luaL_error 将错误传回 Lua 运行时。 这类错误会成为脚本执行失败结果,便于调用方看到明确原因。

激活该 Skill 时,Lua 运行工具(lua_run_scriptlua_run_script_async 等)会对当前 session 可见,同时 Skill 文档作为 activate_skill 的工具返回值注入会话历史,LLM 即可编写并执行使用 myled 模块的脚本。脚本文件通过 write_fileread_filelist_dir 等文件工具创建和检查。

set_directionmode 参数是字符串,C 层集中转换为 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 会额外区分合法的 "disable" 与未知字符串:

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

test/ 目录中的 GPIO 示例可以直接展示最小调用流程:

local gpio = require("gpio")

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

硬件测试脚本应根据实际板级连接选择 GPIO,避免占用启动绑带脚、Flash/PSRAM 管脚或已经被其他外设占用的 IO。

cap_lua 综述 Lua 运行时、脚本管理与异步执行机制
数据流与自动化 Event Router 的 run_script 动作

显示模块有特殊的所有权管理逻辑(「仲裁」机制),确保 Lua 脚本可以独占使用显示资源,避免与其他任务冲突。

lua_module_display 模块在 display.init(...) 成功后,Lua 会自动获取前台显示所有权;display.deinit() 或脚本结束清理时释放所有权,避免与其他显示任务冲突。

另外,在显示 HAL 重新创建场景中,运行时会清理历史残留的 swap buffer / display callback 状态,减少跨脚本切换时的显示资源泄漏风险(特别是配合 lua_run_script_asyncexclusive:"display" + replace:true 切换时)。

event_publisher 向 Event Router 发布事件

Section titled “event_publisher 向 Event Router 发布事件”

publish_message 支持两种形式:

  1. 字符串形式:更加简洁,但可携带的信息较少
local ep = require("event_publisher")
ep.publish_message("Button pressed!")
  1. 消息对象形式:可以携带更多信息,但需要手动构造消息对象
local ep = require("event_publisher")
ep.publish_message({
  source_cap = "lua_script", -- source_cap 必填
  channel = args.channel, -- channel 可选,若未提供,运行时会尝试从全局 args.channel 回填
  chat_id = args.chat_id, -- chat_id 可选,若未提供,运行时会尝试从全局 args.chat_id 回填
  text = "Button pressed!", -- text 必填
})