diff --git a/application/basic_demo/fatfs_image/static/ESP-Claw.png b/application/basic_demo/fatfs_image/static/ESP-Claw.png new file mode 100644 index 0000000..56307a8 Binary files /dev/null and b/application/basic_demo/fatfs_image/static/ESP-Claw.png differ diff --git a/application/basic_demo/main/basic_demo_lua_modules.c b/application/basic_demo/main/basic_demo_lua_modules.c index 71bb699..a3997c3 100644 --- a/application/basic_demo/main/basic_demo_lua_modules.c +++ b/application/basic_demo/main/basic_demo_lua_modules.c @@ -7,6 +7,7 @@ #include "lua_module_adc.h" #include "lua_module_delay.h" +#include "lua_module_capability.h" #include "lua_module_event_publisher.h" #include "lua_module_gpio.h" #include "lua_module_i2c.h" @@ -41,6 +42,11 @@ esp_err_t basic_demo_lua_modules_register(void) return err; } + err = lua_module_capability_register(); + if (err != ESP_OK) { + return err; + } + err = lua_module_storage_register(basic_demo_fatfs_base_path); if (err != ESP_OK) { return err; diff --git a/application/basic_demo/main/idf_component.yml b/application/basic_demo/main/idf_component.yml index 9269c15..e71e242 100644 --- a/application/basic_demo/main/idf_component.yml +++ b/application/basic_demo/main/idf_component.yml @@ -107,6 +107,9 @@ dependencies: lua_module_button: path: ../../../components/lua_modules/lua_module_button + lua_module_call_capability: + path: ../../../components/lua_modules/lua_module_call_capability + lua_module_camera: matches: - if: $CONFIG{ESP_BOARD_DEV_CAMERA_SUPPORT} == True diff --git a/components/lua_modules/lua_module_call_capability/CMakeLists.txt b/components/lua_modules/lua_module_call_capability/CMakeLists.txt new file mode 100644 index 0000000..4507680 --- /dev/null +++ b/components/lua_modules/lua_module_call_capability/CMakeLists.txt @@ -0,0 +1,10 @@ +idf_component_register( + SRCS + "src/lua_module_capability.c" + INCLUDE_DIRS + "include" + REQUIRES + cap_lua + claw_cap + json +) diff --git a/components/lua_modules/lua_module_call_capability/include/lua_module_capability.h b/components/lua_modules/lua_module_call_capability/include/lua_module_capability.h new file mode 100644 index 0000000..583625c --- /dev/null +++ b/components/lua_modules/lua_module_call_capability/include/lua_module_capability.h @@ -0,0 +1,20 @@ +/* + * SPDX-FileCopyrightText: 2026 Espressif Systems (Shanghai) CO LTD + * + * SPDX-License-Identifier: Apache-2.0 + */ +#pragma once + +#include "esp_err.h" +#include "lua.h" + +#ifdef __cplusplus +extern "C" { +#endif + +int luaopen_capability(lua_State *L); +esp_err_t lua_module_capability_register(void); + +#ifdef __cplusplus +} +#endif diff --git a/components/lua_modules/lua_module_call_capability/lua_scripts/basic_qq_send_file.lua b/components/lua_modules/lua_module_call_capability/lua_scripts/basic_qq_send_file.lua new file mode 100644 index 0000000..2f4962b --- /dev/null +++ b/components/lua_modules/lua_module_call_capability/lua_scripts/basic_qq_send_file.lua @@ -0,0 +1,91 @@ +-- QQ send-file demo. +-- Optional args: +-- chat_id: target QQ chat, e.g. "c2c:" or "group:" +-- path: local file path for qq_send_file; if omitted a demo file is created +-- caption: optional caption for qq_send_file +-- If chat_id is omitted, capability.call() may inherit it from the current QQ runtime context. +local capability = require("capability") +local storage = require("storage") + +local a = type(args) == "table" and args or {} + +local function string_arg(key, default) + local value = a[key] + if type(value) == "string" and value ~= "" then + return value + end + return default +end + +local function build_call_opts() + local opts = { + channel = "qq", + source_cap = "lua_builtin_demo", + } + + if type(a.session_id) == "string" and a.session_id ~= "" then + opts.session_id = a.session_id + end + if type(a.chat_id) == "string" and a.chat_id ~= "" then + opts.chat_id = a.chat_id + end + + return opts +end + +local function ensure_demo_file() + local root = storage.get_root_dir() + local demo_dir = storage.join_path(root, "demo") + local demo_path = storage.join_path(demo_dir, "qq_cap_send_file_demo.txt") + local chat_id = string_arg("chat_id", "") + local caption = string_arg("caption", "file from lua qq capability demo") + local lines = { + "ESP-Claw QQ send-file demo file", + "chat_id=" .. chat_id, + "caption=" .. caption, + "This file is generated by lua_module_call_capability/lua_scripts/qq_cap_send_file.lua", + } + + storage.mkdir(demo_dir) + storage.write_file(demo_path, table.concat(lines, "\n") .. "\n") + return demo_path +end + +local function run() + local chat_id = string_arg("chat_id", nil) + local file_path = string_arg("path", nil) + local payload = { + caption = string_arg("caption", "file from lua qq capability demo"), + } + local ok, out, err + + if not file_path then + file_path = ensure_demo_file() + print("[qq_cap_send_file] no args.path provided, generated demo file: " .. file_path) + end + if not storage.exists(file_path) then + error("[qq_cap_send_file] file does not exist: " .. tostring(file_path)) + end + + payload.path = file_path + if chat_id then + payload.chat_id = chat_id + end + + print("[qq_cap_send_file] sending QQ file...") + ok, out, err = capability.call("qq_send_file", payload, build_call_opts()) + print(string.format( + "[qq_cap_send_file] result ok=%s out=%s err=%s", + tostring(ok), tostring(out), tostring(err))) + + if not ok then + error(string.format("qq_send_file failed: %s", tostring(err or out))) + end + + print("[qq_cap_send_file] done") +end + +local ok, err = xpcall(run, debug.traceback) +if not ok then + error(err) +end diff --git a/components/lua_modules/lua_module_call_capability/lua_scripts/basic_qq_send_image.lua b/components/lua_modules/lua_module_call_capability/lua_scripts/basic_qq_send_image.lua new file mode 100644 index 0000000..c31e1b4 --- /dev/null +++ b/components/lua_modules/lua_module_call_capability/lua_scripts/basic_qq_send_image.lua @@ -0,0 +1,69 @@ +-- QQ send-image demo. +-- Optional args: +-- chat_id: target QQ chat, e.g. "c2c:" or "group:" +-- path: local image path for qq_send_image +-- caption: optional caption for qq_send_image +-- If chat_id is omitted, capability.call() may inherit it from the current QQ runtime context. +local capability = require("capability") +local storage = require("storage") + +local a = type(args) == "table" and args or {} + +local function string_arg(key, default) + local value = a[key] + if type(value) == "string" and value ~= "" then + return value + end + return default +end + +local function build_call_opts() + local opts = { + channel = "qq", + source_cap = "lua_builtin_demo", + } + + if type(a.session_id) == "string" and a.session_id ~= "" then + opts.session_id = a.session_id + end + if type(a.chat_id) == "string" and a.chat_id ~= "" then + opts.chat_id = a.chat_id + end + + return opts +end + +local function run() + local chat_id = string_arg("chat_id", nil) + local image_path = string_arg("path", "/fatfs/static/ESP-Claw.png") + local payload = { + caption = string_arg("caption", "image from lua qq capability demo"), + } + local ok, out, err + + if not storage.exists(image_path) then + error("[qq_cap_send_image] file does not exist: " .. tostring(image_path)) + end + + payload.path = image_path + if chat_id then + payload.chat_id = chat_id + end + + print("[qq_cap_send_image] sending QQ image...") + ok, out, err = capability.call("qq_send_image", payload, build_call_opts()) + print(string.format( + "[qq_cap_send_image] result ok=%s out=%s err=%s", + tostring(ok), tostring(out), tostring(err))) + + if not ok then + error(string.format("qq_send_image failed: %s", tostring(err or out))) + end + + print("[qq_cap_send_image] done") +end + +local ok, err = xpcall(run, debug.traceback) +if not ok then + error(err) +end diff --git a/components/lua_modules/lua_module_call_capability/lua_scripts/basic_qq_send_message.lua b/components/lua_modules/lua_module_call_capability/lua_scripts/basic_qq_send_message.lua new file mode 100644 index 0000000..4560b6c --- /dev/null +++ b/components/lua_modules/lua_module_call_capability/lua_scripts/basic_qq_send_message.lua @@ -0,0 +1,61 @@ +-- QQ send-message demo. +-- Optional args: +-- chat_id: target QQ chat, e.g. "c2c:" or "group:" +-- message: text to send with qq_send_message +-- If chat_id is omitted, capability.call() may inherit it from the current QQ runtime context. +local capability = require("capability") + +local a = type(args) == "table" and args or {} + +local function string_arg(key, default) + local value = a[key] + if type(value) == "string" and value ~= "" then + return value + end + return default +end + +local function build_call_opts() + local opts = { + channel = "qq", + source_cap = "lua_builtin_demo", + } + + if type(a.session_id) == "string" and a.session_id ~= "" then + opts.session_id = a.session_id + end + if type(a.chat_id) == "string" and a.chat_id ~= "" then + opts.chat_id = a.chat_id + end + + return opts +end + +local function run() + local chat_id = string_arg("chat_id", nil) + local payload = { + message = string_arg("message", "hello from lua qq capability demo"), + } + local ok, out, err + + if chat_id then + payload.chat_id = chat_id + end + + print("[qq_cap_send_message] sending QQ message...") + ok, out, err = capability.call("qq_send_message", payload, build_call_opts()) + print(string.format( + "[qq_cap_send_message] result ok=%s out=%s err=%s", + tostring(ok), tostring(out), tostring(err))) + + if not ok then + error(string.format("qq_send_message failed: %s", tostring(err or out))) + end + + print("[qq_cap_send_message] done") +end + +local ok, err = xpcall(run, debug.traceback) +if not ok then + error(err) +end diff --git a/components/lua_modules/lua_module_call_capability/skills/skills_list.json b/components/lua_modules/lua_module_call_capability/skills/skills_list.json new file mode 100644 index 0000000..a4ad406 --- /dev/null +++ b/components/lua_modules/lua_module_call_capability/skills/skills_list.json @@ -0,0 +1,12 @@ +{ + "skills": [ + { + "id": "lua_module_capability", + "file": "lua_module_capability.md", + "summary": "How to call registered capabilities from Lua through capability.call(name, payload[, opts]).", + "cap_groups": [ + "cap_lua" + ] + } + ] +} diff --git a/components/lua_modules/lua_module_call_capability/src/lua_module_capability.c b/components/lua_modules/lua_module_call_capability/src/lua_module_capability.c new file mode 100644 index 0000000..fd95c1e --- /dev/null +++ b/components/lua_modules/lua_module_call_capability/src/lua_module_capability.c @@ -0,0 +1,320 @@ +/* + * SPDX-FileCopyrightText: 2026 Espressif Systems (Shanghai) CO LTD + * + * SPDX-License-Identifier: Apache-2.0 + */ +#include "lua_module_capability.h" + +#include +#include +#include +#include + +#include "cJSON.h" +#include "cap_lua.h" +#include "claw_cap.h" +#include "esp_err.h" +#include "lauxlib.h" + +#define LUA_MODULE_CAPABILITY_NAME "capability" +#define LUA_MODULE_CAPABILITY_OUTPUT_SIZE 4096 + +static const char *lua_module_capability_get_string_field(lua_State *L, + int index, + const char *field_name) +{ + const char *value = NULL; + + index = lua_absindex(L, index); + lua_getfield(L, index, field_name); + if (lua_isnil(L, -1)) { + lua_pop(L, 1); + return NULL; + } + if (!lua_isstring(L, -1)) { + lua_pop(L, 1); + luaL_error(L, "field '%s' must be a string", field_name); + } + + value = lua_tostring(L, -1); + lua_pop(L, 1); + return value; +} + +static const char *lua_module_capability_get_args_string_field(lua_State *L, + const char *field_name) +{ + const char *value = NULL; + + lua_getglobal(L, "args"); + if (!lua_istable(L, -1)) { + lua_pop(L, 1); + return NULL; + } + + lua_getfield(L, -1, field_name); + if (lua_isnil(L, -1)) { + lua_pop(L, 2); + return NULL; + } + if (!lua_isstring(L, -1)) { + lua_pop(L, 2); + luaL_error(L, "global args.%s must be a string", field_name); + } + + value = lua_tostring(L, -1); + lua_pop(L, 2); + return value; +} + +static cJSON *lua_module_capability_json_from_value(lua_State *L, int index); + +static bool lua_module_capability_table_is_array(lua_State *L, int index) +{ + lua_Integer expected = 1; + bool has_entries = false; + + index = lua_absindex(L, index); + lua_pushnil(L); + while (lua_next(L, index) != 0) { + if (!lua_isinteger(L, -2) || lua_tointeger(L, -2) != expected) { + lua_pop(L, 2); + return false; + } + has_entries = true; + expected++; + lua_pop(L, 1); + } + + return has_entries; +} + +static cJSON *lua_module_capability_json_from_table(lua_State *L, int index) +{ + cJSON *json = NULL; + + index = lua_absindex(L, index); + if (lua_module_capability_table_is_array(L, index)) { + json = cJSON_CreateArray(); + lua_pushnil(L); + while (lua_next(L, index) != 0) { + cJSON *child = lua_module_capability_json_from_value(L, -1); + + lua_pop(L, 1); + if (!child || !cJSON_AddItemToArray(json, child)) { + cJSON_Delete(child); + cJSON_Delete(json); + return NULL; + } + } + return json; + } + + json = cJSON_CreateObject(); + lua_pushnil(L); + while (lua_next(L, index) != 0) { + const char *key = NULL; + char key_buf[32]; + cJSON *child = NULL; + + if (lua_type(L, -2) == LUA_TSTRING) { + key = lua_tostring(L, -2); + } else if (lua_isinteger(L, -2)) { + snprintf(key_buf, sizeof(key_buf), "%lld", (long long)lua_tointeger(L, -2)); + key = key_buf; + } else { + lua_pop(L, 2); + cJSON_Delete(json); + return NULL; + } + + child = lua_module_capability_json_from_value(L, -1); + lua_pop(L, 1); + if (!child || !cJSON_AddItemToObject(json, key, child)) { + cJSON_Delete(child); + cJSON_Delete(json); + return NULL; + } + } + + return json; +} + +static cJSON *lua_module_capability_json_from_value(lua_State *L, int index) +{ + index = lua_absindex(L, index); + + switch (lua_type(L, index)) { + case LUA_TNIL: + return cJSON_CreateNull(); + case LUA_TBOOLEAN: + return cJSON_CreateBool(lua_toboolean(L, index)); + case LUA_TNUMBER: + return cJSON_CreateNumber(lua_tonumber(L, index)); + case LUA_TSTRING: + return cJSON_CreateString(lua_tostring(L, index)); + case LUA_TTABLE: + return lua_module_capability_json_from_table(L, index); + default: + return NULL; + } +} + +static char *lua_module_capability_build_payload_json(lua_State *L, int index) +{ + cJSON *json = NULL; + char *payload_json = NULL; + + if (lua_isnoneornil(L, index)) { + payload_json = strdup("{}"); + if (!payload_json) { + luaL_error(L, "out of memory"); + } + return payload_json; + } + + if (lua_type(L, index) == LUA_TSTRING) { + cJSON *parsed = cJSON_Parse(lua_tostring(L, index)); + + if (!parsed) { + luaL_error(L, "payload string must be valid JSON"); + } + cJSON_Delete(parsed); + + payload_json = strdup(lua_tostring(L, index)); + if (!payload_json) { + luaL_error(L, "out of memory"); + } + return payload_json; + } + + if (!lua_istable(L, index)) { + luaL_error(L, "payload must be nil, a table, or a JSON string"); + } + + json = lua_module_capability_json_from_value(L, index); + if (!json) { + luaL_error(L, "failed to convert payload to JSON"); + } + + payload_json = cJSON_PrintUnformatted(json); + cJSON_Delete(json); + if (!payload_json) { + luaL_error(L, "failed to serialize payload as JSON"); + } + + return payload_json; +} + +static void lua_module_capability_fill_context(lua_State *L, int opts_index, claw_cap_call_context_t *ctx) +{ + const char *value = NULL; + + memset(ctx, 0, sizeof(*ctx)); + ctx->caller = CLAW_CAP_CALLER_SYSTEM; + + if (!lua_isnoneornil(L, opts_index)) { + if (!lua_istable(L, opts_index)) { + luaL_error(L, "opts must be a table"); + } + + value = lua_module_capability_get_string_field(L, opts_index, "session_id"); + if (value && value[0]) { + ctx->session_id = value; + } + + value = lua_module_capability_get_string_field(L, opts_index, "channel"); + if (value && value[0]) { + ctx->channel = value; + } + + value = lua_module_capability_get_string_field(L, opts_index, "chat_id"); + if (value && value[0]) { + ctx->chat_id = value; + } + + value = lua_module_capability_get_string_field(L, opts_index, "source_cap"); + if (value && value[0]) { + ctx->source_cap = value; + } + } + + if (!ctx->session_id) { + value = lua_module_capability_get_args_string_field(L, "session_id"); + if (value && value[0]) { + ctx->session_id = value; + } + } + if (!ctx->channel) { + value = lua_module_capability_get_args_string_field(L, "channel"); + if (value && value[0]) { + ctx->channel = value; + } + } + if (!ctx->chat_id) { + value = lua_module_capability_get_args_string_field(L, "chat_id"); + if (value && value[0]) { + ctx->chat_id = value; + } + } + if (!ctx->source_cap) { + value = lua_module_capability_get_args_string_field(L, "source_cap"); + if (value && value[0]) { + ctx->source_cap = value; + } + } +} + +static int lua_module_capability_call(lua_State *L) +{ + const char *cap_name = luaL_checkstring(L, 1); + claw_cap_call_context_t ctx = {0}; + char *payload_json = NULL; + char *output = NULL; + esp_err_t err; + + payload_json = lua_module_capability_build_payload_json(L, 2); + lua_module_capability_fill_context(L, 3, &ctx); + + output = calloc(1, LUA_MODULE_CAPABILITY_OUTPUT_SIZE); + if (!output) { + free(payload_json); + return luaL_error(L, "out of memory"); + } + + err = claw_cap_call(cap_name, payload_json, &ctx, output, LUA_MODULE_CAPABILITY_OUTPUT_SIZE); + free(payload_json); + + if (err == ESP_OK) { + lua_pushboolean(L, 1); + lua_pushstring(L, output); + lua_pushnil(L); + free(output); + return 3; + } + + lua_pushboolean(L, 0); + if (output[0]) { + lua_pushstring(L, output); + } else { + lua_pushnil(L); + } + lua_pushstring(L, esp_err_to_name(err)); + free(output); + return 3; +} + +int luaopen_capability(lua_State *L) +{ + lua_newtable(L); + + lua_pushcfunction(L, lua_module_capability_call); + lua_setfield(L, -2, "call"); + + return 1; +} + +esp_err_t lua_module_capability_register(void) +{ + return cap_lua_register_module(LUA_MODULE_CAPABILITY_NAME, luaopen_capability); +}