.md`
- Or put default skills in `spiffs_data/skills/` so they’re in the SPIFFS image
-- Or add built-in skills in `ec_skill_loader.c`
Suggested format:
diff --git a/README_ZH.md b/README_ZH.md
index 73f6947..8f57af2 100644
--- a/README_ZH.md
+++ b/README_ZH.md
@@ -4,6 +4,8 @@
+
+
**把 LLM、Tools、Agent、Channel 彻底拆开,再把它们装进一块 ESP32-S3。**
[](LICENSE)     
@@ -17,7 +19,7 @@
本仓库在理念和方向上参考了以下优秀项目:
-- [OpenClaw](https://github.com/OpenClawAI/OpenClaw)
+- [OpenClaw](https://github.com/openclaw/openclaw)
- [MimiClaw](https://github.com/memovai/mimiclaw)
EmbedClaw 延续了“在低功耗硬件上运行完整 AI Agent”的思路,但把架构重点放在了 **LLM、Tools、Agent、Channel 的解耦** 上。
@@ -68,9 +70,9 @@ EmbedClaw 延续了“在低功耗硬件上运行完整 AI Agent”的思路,
| Chat Channel | Feishu、WebSocket、QQBot | 飞书长连接、本地 WebSocket 对话、官方 QQBot gateway |
| Agent | ReAct Tool Loop | 支持模型调用工具、再读工具结果、再继续推理 |
| 长期记忆 | `/spiffs/memory/MEMORY.md` | 用户画像、长期偏好、稳定事实 |
-| 短期记忆 | `/spiffs/session/se_.jsonl` | 最近对话历史,供当前会话上下文使用 |
+| 短期记忆 | `/spiffs/session/se_.jsonl` | 对话历史(含完整工具调用链),供当前会话上下文使用 |
| 每日笔记 | `/spiffs/memory/.md` | 记录近期事件与每日上下文 |
-| Skills | 内置 + SPIFFS 动态技能 | 任务指令可持久化为 Markdown |
+| Skills | SPIFFS 预置 + 运行时动态添加 | 任务指令可持久化为 Markdown |
| Tools | 文件、时间、搜索、定时任务 | 通过统一 JSON Schema 暴露给 LLM |
### 已注册 Tools
@@ -86,10 +88,11 @@ EmbedClaw 延续了“在低功耗硬件上运行完整 AI Agent”的思路,
| `cron_add` | 创建周期任务或单次任务 |
| `cron_list` | 查看当前定时任务 |
| `cron_remove` | 删除定时任务 |
+| `gpio_control` | 控制 ESP32 GPIO 引脚(on/off/set/toggle/get) |
-### 已启用 Skills
+### 预置 Skills
-系统启动时会自动安装内置 Skills:
+以下技能以 Markdown 文件形式预置在 `spiffs_data/skills/` 中,随 SPIFFS 镜像一同烧录:
- `weather`
- `daily-briefing`
@@ -114,6 +117,7 @@ flowchart LR
T --> S2[File Tools]
T --> S3[Time Tool]
T --> S4[Cron Tool]
+ T --> S5[GPIO Tool]
A --> M1[Session Memory]
A --> M2[Long-term Memory]
A --> K[Skill Loader]
@@ -136,6 +140,9 @@ flowchart LR
│ ├── embed_claw.c # 系统统一启动入口
│ └── ec_config_internal.h # 仓库内置默认配置,项目覆盖放在 main/ec_config.h
├── spiffs_data/ # 默认写入 SPIFFS 的系统文件
+│ ├── config/ # SOUL.md、USER.md
+│ ├── memory/ # MEMORY.md
+│ └── skills/ # 预置 Skill 文件
└── scripts/ # WebSocket 测试脚本与测试构建辅助脚本
```
@@ -569,7 +576,6 @@ Skill 是最轻量的扩展方式。它不是代码,而是任务说明书。
- 在运行时通过 Tool 把 Skill 写入 `/spiffs/skills/.md`
- 或者把默认 Skill 放进 `spiffs_data/skills/`,随 SPIFFS 镜像一同烧录
-- 或者在 `ec_skill_loader.c` 中增加新的内置 Skill
建议格式:
diff --git a/TODO.md b/TODO.md
index 2ed33d4..374eb68 100644
--- a/TODO.md
+++ b/TODO.md
@@ -17,6 +17,7 @@
- [x] Web search
- [ ] Script interpreter
- [ ] Peripherals (GPIO, I2C, etc.)
+ - [X] GPIO
- [ ] Camera
## Channel integration
@@ -33,3 +34,5 @@
- [x] test and ci/cd
- [ ] Support more filesystems (beyond SPIFFS)
- [ ] SD card support
+- [ ] Support ESP32C3
+
diff --git a/TODO_ZH.md b/TODO_ZH.md
index 7bc7188..edf5edb 100644
--- a/TODO_ZH.md
+++ b/TODO_ZH.md
@@ -17,6 +17,7 @@
- [x] 对接web搜索
- [ ] 对接脚本语言解释器
- [ ] 对接外设
+ - [X] 对接GPIO
- [ ] 对接摄像头
## Channel对接
@@ -33,3 +34,4 @@
- [x] 测试和ci/cd
- [ ] 更改SPIFFS为更多的文件系统
- [ ] 支持 SD card
+- [ ] 支持 ESP32C3
diff --git a/assets/logo.png b/assets/logo.png
new file mode 100644
index 0000000..74c1af0
Binary files /dev/null and b/assets/logo.png differ
diff --git a/components/embed_claw/CMakeLists.txt b/components/embed_claw/CMakeLists.txt
index 3898e55..7239ccf 100644
--- a/components/embed_claw/CMakeLists.txt
+++ b/components/embed_claw/CMakeLists.txt
@@ -8,6 +8,7 @@ idf_component_register(
INCLUDE_DIRS
"."
REQUIRES
+ driver
esp_http_client
esp_http_server
esp_netif
diff --git a/components/embed_claw/core/ec_agent.c b/components/embed_claw/core/ec_agent.c
index fedd18c..5911eb0 100644
--- a/components/embed_claw/core/ec_agent.c
+++ b/components/embed_claw/core/ec_agent.c
@@ -41,21 +41,15 @@
#define EC_AGENT_CHANNEL_DELIVERY_TASK_STACK (8 * 1024)
#define EC_AGENT_CHANNEL_DELIVERY_TASK_PRIO 5
-#define EC_AGENT_SYSTEM_PROMPT_STR \
+#define EC_AGENT_SYSTEM_PROMPT_HEAD \
"You are EmbedClaw, a helpful and concise AI assistant running on an ESP32 device.\n"\
"You communicate via Feishu and WebSocket.\n"\
"Reply briefly to short messages (e.g. 你好, 在吗, 谢谢).\n"\
"# Tools\n"\
- "- web_search: search current information.\n"\
- "- get_current_time: get date/time.\n"\
- "- read_file: read /spiffs/ files (path must start with " EC_FS_BASE "/).\n"\
- "- write_file: Write file.\n"\
- "- edit_file: edit file.\n"\
- "- list_dir: list files.\n"\
- "- cron_add: schedule task.\n"\
- "- cron_list: list tasks.\n"\
- "- cron_remove: remove task.\n\n"\
- "When using cron_add to reply later in the same conversation, reuse the current channel, chat_type, and chat_id.\n\n"\
+ "Available tools:\n"
+
+#define EC_AGENT_SYSTEM_PROMPT_TAIL \
+ "\nWhen using cron_add to reply later in the same conversation, reuse the current channel, chat_type, and chat_id.\n\n"\
"Use tools when needed. Provide your final answer as text after using tools.\n\n"\
"## Memory\n"\
"You have persistent memory stored on local flash:\n"\
@@ -328,19 +322,34 @@ static esp_err_t context_build_system_prompt(char *buf, size_t size)
size_t off = 0;
size_t cap = size - 1;
- off += snprintf(buf + off, size - off, EC_AGENT_SYSTEM_PROMPT_STR);
+ off += snprintf(buf + off, size - off, EC_AGENT_SYSTEM_PROMPT_HEAD);
if (off > cap) {
off = cap;
}
- /* Bootstrap files */
- off = append_file(buf, size, off, EC_SOUL_FILE, "Personality");
- off = append_file(buf, size, off, EC_USER_FILE, "User Info");
-
scratch = calloc(1, EC_AGENT_PROMPT_SCRATCH_SIZE);
if (!scratch) {
ESP_LOGW(TAG, "Skipping optional prompt sections: out of memory");
} else {
+ size_t tools_len = ec_tools_build_summary(scratch, EC_AGENT_PROMPT_SCRATCH_SIZE);
+ if (off < cap && tools_len > 0) {
+ off += snprintf(buf + off, size - off, "%s", scratch);
+ if (off > cap) {
+ off = cap;
+ }
+ }
+ }
+
+ off += snprintf(buf + off, size - off, EC_AGENT_SYSTEM_PROMPT_TAIL);
+ if (off > cap) {
+ off = cap;
+ }
+
+ /* Bootstrap files */
+ off = append_file(buf, size, off, EC_SOUL_FILE, "Personality");
+ off = append_file(buf, size, off, EC_USER_FILE, "User Info");
+
+ if (scratch) {
if (off < cap && ec_memory_read_long_term(scratch, EC_AGENT_PROMPT_SCRATCH_SIZE) == ESP_OK && scratch[0]) {
off += snprintf(buf + off, size - off, "\n## Long-term Memory\n\n%s\n", scratch);
if (off > cap) {
@@ -377,7 +386,6 @@ static esp_err_t context_build_system_prompt(char *buf, size_t size)
}
ESP_LOGI(TAG, "System prompt built: %d bytes", (int)off);
- ESP_LOGI(TAG, "prompt:%s", buf);
return ESP_OK;
}
@@ -455,6 +463,8 @@ static void agent_loop_task(void *arg)
if (!messages) {
messages = cJSON_CreateArray();
}
+ int history_count = cJSON_GetArraySize(messages);
+
cJSON *user_msg = cJSON_CreateObject();
cJSON_AddStringToObject(user_msg, "role", "user");
cJSON_AddStringToObject(user_msg, "content", msg.content);
@@ -488,7 +498,6 @@ static void agent_loop_task(void *arg)
}
if (!resp.tool_use) {
- // 正常对话结束,保存最终文本并退出循环
if (resp.text && resp.text_len > 0) {
final_text = strdup(resp.text);
}
@@ -496,14 +505,11 @@ static void agent_loop_task(void *arg)
break;
}
-
- // 构建助手消息,包含文本回复和工具调用信息,供工具执行结果使用
cJSON *asst_msg = cJSON_CreateObject();
cJSON_AddStringToObject(asst_msg, "role", "assistant");
cJSON_AddItemToObject(asst_msg, "content", build_assistant_content(&resp));
cJSON_AddItemToArray(messages, asst_msg);
- // 执行工具并将结果追加到消息数组中,供下一轮 LLM 调用使用
cJSON *tool_results = build_tool_results(&resp, &msg, tool_output, EC_AGENT_TOOL_OUTPUT_SIZE);
cJSON *result_msg = cJSON_CreateObject();
cJSON_AddStringToObject(result_msg, "role", "user");
@@ -514,14 +520,19 @@ static void agent_loop_task(void *arg)
}
if (final_text && final_text[0]) {
- // 保存用户消息和助手回复到会话历史中
- esp_err_t save_user = ec_session_append(session_key, "user", msg.content);
- esp_err_t save_asst = ec_session_append(session_key, "assistant", final_text);
- if (save_user != ESP_OK || save_asst != ESP_OK) {
- ESP_LOGW(TAG, "Session save failed for %s:%s:%s (user=%s, assistant=%s)",
- msg.channel, msg.chat_type, msg.chat_id,
- esp_err_to_name(save_user),
- esp_err_to_name(save_asst));
+ int total = cJSON_GetArraySize(messages);
+ bool save_ok = true;
+ for (int k = history_count; k < total; k++) {
+ if (ec_session_append_msg(session_key, cJSON_GetArrayItem(messages, k)) != ESP_OK) {
+ save_ok = false;
+ }
+ }
+ if (ec_session_append(session_key, "assistant", final_text) != ESP_OK) {
+ save_ok = false;
+ }
+ if (!save_ok) {
+ ESP_LOGW(TAG, "Session save failed for %s:%s:%s",
+ msg.channel, msg.chat_type, msg.chat_id);
} else {
ESP_LOGI(TAG, "Session saved for %s:%s:%s", msg.channel, msg.chat_type, msg.chat_id);
}
diff --git a/components/embed_claw/core/ec_session.c b/components/embed_claw/core/ec_session.c
index 8bcb783..7d05668 100644
--- a/components/embed_claw/core/ec_session.c
+++ b/components/embed_claw/core/ec_session.c
@@ -26,6 +26,7 @@
/* ==================== [Defines] =========================================== */
#define EC_SESSION_PATH_MAX 128
+#define EC_SESSION_LINE_MAX 4096
/* ==================== [Typedefs] ========================================== */
@@ -70,6 +71,40 @@ esp_err_t ec_session_append(const char *chat_id, const char *role, const char *c
return ESP_OK;
}
+esp_err_t ec_session_append_msg(const char *chat_id, const cJSON *msg)
+{
+ if (!chat_id || !msg) {
+ return ESP_ERR_INVALID_ARG;
+ }
+
+ char path[EC_SESSION_PATH_MAX];
+ session_path(chat_id, path, sizeof(path));
+
+ FILE *f = fopen(path, "a");
+ if (!f) {
+ ESP_LOGE(TAG, "Cannot open session file %s", path);
+ return ESP_FAIL;
+ }
+
+ cJSON *copy = cJSON_Duplicate(msg, 1);
+ if (!copy) {
+ fclose(f);
+ return ESP_ERR_NO_MEM;
+ }
+ cJSON_AddNumberToObject(copy, "ts", (double)time(NULL));
+
+ char *line = cJSON_PrintUnformatted(copy);
+ cJSON_Delete(copy);
+
+ if (line) {
+ fprintf(f, "%s\n", line);
+ free(line);
+ }
+
+ fclose(f);
+ return ESP_OK;
+}
+
esp_err_t ec_session_get_history_json(const char *chat_id, char *buf, size_t size, int max_msgs)
{
if (!buf || size == 0) {
@@ -101,9 +136,8 @@ esp_err_t ec_session_get_history_json(const char *chat_id, char *buf, size_t siz
int count = 0;
int write_idx = 0;
- char line[2048];
+ char line[EC_SESSION_LINE_MAX];
while (fgets(line, sizeof(line), f)) {
- /* Strip newline */
size_t len = strlen(line);
if (len > 0 && line[len - 1] == '\n') line[len - 1] = '\0';
if (line[0] == '\0') continue;
@@ -111,7 +145,6 @@ esp_err_t ec_session_get_history_json(const char *chat_id, char *buf, size_t siz
cJSON *obj = cJSON_Parse(line);
if (!obj) continue;
- /* Ring buffer: overwrite oldest if full */
if (count >= history_limit) {
cJSON_Delete(messages[write_idx]);
}
@@ -121,7 +154,7 @@ esp_err_t ec_session_get_history_json(const char *chat_id, char *buf, size_t siz
}
fclose(f);
- /* Build JSON array with only role + content */
+ /* Build JSON array preserving full message structure */
cJSON *arr = cJSON_CreateArray();
int start = (count < history_limit) ? 0 : write_idx;
for (int i = 0; i < count; i++) {
@@ -129,17 +162,16 @@ esp_err_t ec_session_get_history_json(const char *chat_id, char *buf, size_t siz
cJSON *src = messages[idx];
cJSON *role = cJSON_GetObjectItem(src, "role");
- cJSON *content = cJSON_GetObjectItem(src, "content");
- if (!cJSON_IsString(role) || !role->valuestring ||
- !cJSON_IsString(content) || !content->valuestring) {
+ if (!cJSON_IsString(role) || !role->valuestring) {
ESP_LOGW(TAG, "Skipping malformed session entry in %s", path);
continue;
}
- cJSON *entry = cJSON_CreateObject();
- cJSON_AddStringToObject(entry, "role", role->valuestring);
- cJSON_AddStringToObject(entry, "content", content->valuestring);
- cJSON_AddItemToArray(arr, entry);
+ cJSON *entry = cJSON_Duplicate(src, 1);
+ if (entry) {
+ cJSON_DeleteItemFromObject(entry, "ts");
+ cJSON_AddItemToArray(arr, entry);
+ }
}
/* Cleanup ring buffer */
@@ -150,6 +182,7 @@ esp_err_t ec_session_get_history_json(const char *chat_id, char *buf, size_t siz
}
char *json_str = cJSON_PrintUnformatted(arr);
+ ESP_LOGI(TAG, "Session history for %s:\n%s", chat_id, json_str);
cJSON_Delete(arr);
if (json_str) {
diff --git a/components/embed_claw/core/ec_session.h b/components/embed_claw/core/ec_session.h
index f28fa1a..afae045 100644
--- a/components/embed_claw/core/ec_session.h
+++ b/components/embed_claw/core/ec_session.h
@@ -15,6 +15,7 @@
/* ==================== [Includes] ========================================== */
#include "esp_err.h"
+#include "cJSON.h"
#include
#ifdef __cplusplus
@@ -39,6 +40,17 @@ extern "C" {
*/
esp_err_t ec_session_append(const char *chat_id, const char *role, const char *content);
+/**
+ * @brief 添加一条完整的 cJSON 消息对象到会话历史(保留 tool_calls、数组 content 等结构)。
+ *
+ * @param chat_id 会话id
+ * @param msg cJSON 消息对象(不会被修改或释放)
+ * @return esp_err_t
+ * - ESP_OK 成功
+ * - ESP_FAIL 写入失败
+ */
+esp_err_t ec_session_append_msg(const char *chat_id, const cJSON *msg);
+
/**
* @brief 获取会话历史的JSON字符串,格式为:
* [{"role":"user","content":"..."},{"role":"assistant","content":"..."},...]
diff --git a/components/embed_claw/core/ec_skill_loader.c b/components/embed_claw/core/ec_skill_loader.c
index 758e7c6..5b6afca 100644
--- a/components/embed_claw/core/ec_skill_loader.c
+++ b/components/embed_claw/core/ec_skill_loader.c
@@ -21,98 +21,10 @@
/* ==================== [Defines] =========================================== */
-/* ── Built-in skill contents ─────────────────────────────────── */
-
-#define EC_SKILL_LOADER_BUILTIN_WEATHER \
- "# Weather\n" \
- "\n" \
- "Get current weather and forecasts using web_search.\n" \
- "\n" \
- "## When to use\n" \
- "When the user asks about weather, temperature, or forecasts.\n" \
- "\n" \
- "## How to use\n" \
- "1. Use get_current_time to know the current date\n" \
- "2. Use web_search with a query like \"weather in [city] today\"\n" \
- "3. Extract temperature, conditions, and forecast from results\n" \
- "4. Present in a concise, friendly format\n" \
- "\n" \
- "## Example\n" \
- "User: \"What's the weather in Tokyo?\"\n" \
- "→ get_current_time\n" \
- "→ web_search \"weather Tokyo today February 2026\"\n" \
- "→ \"Tokyo: 8°C, partly cloudy. High 12°C, low 4°C. Light wind from the north.\"\n"
-
-#define EC_SKILL_LOADER_BUILTIN_DAILY_BRIEFING \
- "# Daily Briefing\n" \
- "\n" \
- "Compile a personalized daily briefing for the user.\n" \
- "\n" \
- "## When to use\n" \
- "When the user asks for a daily briefing, morning update, or \"what's new today\".\n" \
- "Also useful as a heartbeat/cron task.\n" \
- "\n" \
- "## How to use\n" \
- "1. Use get_current_time for today's date\n" \
- "2. Read " EC_FS_MEMORY_DIR "/MEMORY.md for user preferences and context\n" \
- "3. Read today's daily note if it exists\n" \
- "4. Use web_search for relevant news based on user interests\n" \
- "5. Compile a concise briefing covering:\n" \
- " - Date and time\n" \
- " - Weather (if location known from USER.md)\n" \
- " - Relevant news/updates based on user interests\n" \
- " - Any pending tasks from memory\n" \
- " - Any scheduled cron jobs\n" \
- "\n" \
- "## Format\n" \
- "Keep it brief — 5-10 bullet points max. Use the user's preferred language.\n"
-
-#define EC_SKILL_LOADER_BUILTIN_SKILL_CREATOR \
- "# Skill Creator\n" \
- "\n" \
- "Create new skills for EmbedClaw.\n" \
- "\n" \
- "## When to use\n" \
- "When the user asks to create a new skill, teach the bot something, or add a new capability.\n" \
- "\n" \
- "## How to create a skill\n" \
- "1. Choose a short, descriptive name (lowercase, hyphens ok)\n" \
- "2. Write a SKILL.md file with this structure:\n" \
- " - `# Title` — clear name\n" \
- " - Brief description paragraph\n" \
- " - `## When to use` — trigger conditions\n" \
- " - `## How to use` — step-by-step instructions\n" \
- " - `## Example` — concrete example (optional but helpful)\n" \
- "3. Save to `" EC_SKILLS_PREFIX ".md` using write_file\n" \
- "4. The skill will be automatically available after the next conversation\n" \
- "\n" \
- "## Best practices\n" \
- "- Keep skills concise — the context window is limited\n" \
- "- Focus on WHAT to do, not HOW (the agent is smart)\n" \
- "- Include specific tool calls the agent should use\n" \
- "- Test by asking the agent to use the new skill\n" \
- "\n" \
- "## Example\n" \
- "To create a \"translate\" skill:\n" \
- "write_file path=\"" EC_SKILLS_PREFIX "translate.md\" content=\"# Translate\\n\\nTranslate text between languages.\\n\\n" \
- "## When to use\\nWhen the user asks to translate text.\\n\\n" \
- "## How to use\\n1. Identify source and target languages\\n" \
- "2. Translate directly using your language knowledge\\n" \
- "3. For specialized terms, use web_search to verify\\n\"\n"
-
-#define EC_SKILL_LOADER_NUM_BUILTINS (sizeof(s_builtins) / sizeof(s_builtins[0]))
-
/* ==================== [Typedefs] ========================================== */
-/* Built-in skill registry */
-typedef struct {
- const char *filename; /* e.g. "weather" */
- const char *content;
-} builtin_skill_t;
-
/* ==================== [Static Prototypes] ================================= */
-static void install_builtin(const builtin_skill_t *skill);
static const char *extract_title(const char *line, size_t len, char *out, size_t out_size);
static void extract_description(FILE *f, char *out, size_t out_size);
@@ -120,25 +32,13 @@ static void extract_description(FILE *f, char *out, size_t out_size);
static const char *TAG = "skills";
-static const builtin_skill_t s_builtins[] = {
- { "weather", EC_SKILL_LOADER_BUILTIN_WEATHER },
- { "daily-briefing", EC_SKILL_LOADER_BUILTIN_DAILY_BRIEFING },
- { "skill-creator", EC_SKILL_LOADER_BUILTIN_SKILL_CREATOR },
-};
-
/* ==================== [Macros] ============================================ */
/* ==================== [Global Functions] ================================== */
esp_err_t ec_skill_loader_init(void)
{
- ESP_LOGI(TAG, "Initializing skills system");
-
- for (size_t i = 0; i < EC_SKILL_LOADER_NUM_BUILTINS; i++) {
- install_builtin(&s_builtins[i]);
- }
-
- ESP_LOGI(TAG, "Skills system ready (%d built-in)", (int)EC_SKILL_LOADER_NUM_BUILTINS);
+ ESP_LOGI(TAG, "Skills system ready (skills loaded from SPIFFS)");
return ESP_OK;
}
@@ -206,35 +106,6 @@ size_t ec_skill_loader_build_summary(char *buf, size_t size)
/* ==================== [Static Functions] ================================== */
-/* ── Install built-in skills if missing ──────────────────────── */
-
-static void install_builtin(const builtin_skill_t *skill)
-{
- char path[64];
- snprintf(path, sizeof(path), "%s%s.md", EC_SKILLS_PREFIX, skill->filename);
-
- /* Check if already exists */
- FILE *f = fopen(path, "r");
- if (f) {
- fclose(f);
- ESP_LOGD(TAG, "Skill exists: %s", path);
- return;
- }
-
- /* Write built-in skill */
- f = fopen(path, "w");
- if (!f) {
- ESP_LOGE(TAG, "Cannot write skill: %s", path);
- return;
- }
-
- fputs(skill->content, f);
- fclose(f);
- ESP_LOGI(TAG, "Installed built-in skill: %s", path);
-}
-
-/* ── Build skills summary for system prompt ──────────────────── */
-
/**
* Parse first line as title: expects "# Title"
* Returns pointer past "# " or the line itself if no prefix.
diff --git a/components/embed_claw/core/ec_tools.c b/components/embed_claw/core/ec_tools.c
index 2a7be9c..2434542 100644
--- a/components/embed_claw/core/ec_tools.c
+++ b/components/embed_claw/core/ec_tools.c
@@ -108,6 +108,35 @@ const char *ec_tools_get_json(void)
return s_tools_json;
}
+size_t ec_tools_build_summary(char *buf, size_t size)
+{
+ size_t off = 0;
+
+ if (!buf || size == 0) {
+ return 0;
+ }
+
+ for (size_t i = 0; i < _EC_TOOLS_ENMU_MAX && off < size - 1; i++) {
+ const char *name = NULL;
+ const char *description = NULL;
+
+ if (!s_tools[i]) {
+ continue;
+ }
+
+ name = s_tools[i]->name ? s_tools[i]->name : "(unnamed)";
+ description = s_tools[i]->description ? s_tools[i]->description : "";
+
+ off += snprintf(buf + off, size - off, "- %s: %s\n", name, description);
+ if (off >= size) {
+ off = size - 1;
+ }
+ }
+
+ buf[off] = '\0';
+ return off;
+}
+
void ec_tools_free_json(void)
{
cJSON_free(s_tools_json);
diff --git a/components/embed_claw/core/ec_tools.h b/components/embed_claw/core/ec_tools.h
index 4e4fbe9..c452414 100644
--- a/components/embed_claw/core/ec_tools.h
+++ b/components/embed_claw/core/ec_tools.h
@@ -82,6 +82,15 @@ esp_err_t ec_tools_execute(const char *name, const char *input_json, char *outpu
*/
const char *ec_tools_get_json(void);
+/**
+ * @brief 构建所有已注册工具的摘要文本,用于 system prompt
+ *
+ * @param buf 输出缓冲区
+ * @param size 缓冲区大小
+ * @return size_t 写入的字节数
+ */
+size_t ec_tools_build_summary(char *buf, size_t size);
+
/**
* @brief 释放json中申请的字符串内存
*
diff --git a/components/embed_claw/llm/ec_llm_openai.c b/components/embed_claw/llm/ec_llm_openai.c
index 4c0ed22..a32145d 100644
--- a/components/embed_claw/llm/ec_llm_openai.c
+++ b/components/embed_claw/llm/ec_llm_openai.c
@@ -197,6 +197,8 @@ static esp_err_t ec_llm_openai_chat_tools(ec_llm_provider_t *self, const char *s
ESP_LOGI(TAG, "Calling LLM API with tools (model=%s, body=%u bytes)",
self->instance.model, (unsigned)strlen(post_data));
+ ESP_LOGI(TAG, "LLM API request body: %s", post_data);
+
resp_buf_t rb = {
.data = calloc(1, EC_LLM_STREAM_BUF_SIZE),
.len = 0,
@@ -258,6 +260,7 @@ static esp_err_t ec_llm_openai_chat_tools(ec_llm_provider_t *self, const char *s
return ESP_FAIL;
}
+ ESP_LOGI(TAG, "API response JSON: %s", rb.data);
resp_buf_free(&rb);
cJSON *choices = cJSON_GetObjectItem(root, "choices");
@@ -322,7 +325,7 @@ static esp_err_t ec_llm_openai_chat_tools(ec_llm_provider_t *self, const char *s
}
cJSON_Delete(root);
-
+ ESP_LOGI(TAG, "Response text: %s", resp->text ? resp->text : "");
ESP_LOGI(TAG, "Response: %d bytes text, %d tool calls, stop=%s",
(int)resp->text_len, resp->call_count,
resp->tool_use ? "tool_use" : "end_turn");
@@ -525,46 +528,55 @@ static esp_err_t http_event_handler(esp_http_client_event_t *evt)
return ESP_OK;
}
+static esp_http_client_handle_t s_llm_client = NULL;
+
static esp_err_t llm_http(const char *post_data, resp_buf_t *rb, int *out_status)
{
- const char *cert_pem = select_server_ca_pem(s_provider.instance.url);
- esp_http_client_config_t config = {
- .url = s_provider.instance.url,
- .method = HTTP_METHOD_POST,
- .event_handler = http_event_handler,
- .user_data = rb,
- .timeout_ms = 120 * 1000,
- .buffer_size = 4096,
- .buffer_size_tx = 4096,
- .transport_type = HTTP_TRANSPORT_OVER_SSL,
- };
-
- if (cert_pem) {
- config.cert_pem = cert_pem;
- config.cert_len = 0;
- ESP_LOGI(TAG, "Using pinned root CA for %s", s_provider.instance.url);
- } else {
- config.crt_bundle_attach = esp_crt_bundle_attach;
- }
+ if (!s_llm_client) {
+ const char *cert_pem = select_server_ca_pem(s_provider.instance.url);
+ esp_http_client_config_t config = {
+ .url = s_provider.instance.url,
+ .method = HTTP_METHOD_POST,
+ .event_handler = http_event_handler,
+ .user_data = rb,
+ .timeout_ms = 120 * 1000,
+ .buffer_size = 4096,
+ .buffer_size_tx = 4096,
+ .transport_type = HTTP_TRANSPORT_OVER_SSL,
+ .keep_alive_enable = true,
+ };
+
+ if (cert_pem) {
+ config.cert_pem = cert_pem;
+ config.cert_len = 0;
+ ESP_LOGI(TAG, "Using pinned root CA for %s", s_provider.instance.url);
+ } else {
+ config.crt_bundle_attach = esp_crt_bundle_attach;
+ }
- esp_http_client_handle_t client = esp_http_client_init(&config);
- if (!client) {
- return ESP_FAIL;
- }
+ s_llm_client = esp_http_client_init(&config);
+ if (!s_llm_client) {
+ return ESP_FAIL;
+ }
- esp_http_client_set_method(client, HTTP_METHOD_POST);
- esp_http_client_set_header(client, "Content-Type", "application/json; charset=utf-8");
+ esp_http_client_set_header(s_llm_client, "Content-Type", "application/json; charset=utf-8");
- char auth[EC_LLM_OPENAI_API_KEY_MAX_LEN + 16];
- snprintf(auth, sizeof(auth), "Bearer %s", s_provider.instance.api_key);
- esp_http_client_set_header(client, "Authorization", auth);
+ char auth[EC_LLM_OPENAI_API_KEY_MAX_LEN + 16];
+ snprintf(auth, sizeof(auth), "Bearer %s", s_provider.instance.api_key);
+ esp_http_client_set_header(s_llm_client, "Authorization", auth);
+ }
- esp_http_client_set_post_field(client, post_data, strlen(post_data));
+ esp_http_client_set_user_data(s_llm_client, rb);
+ esp_http_client_set_post_field(s_llm_client, post_data, strlen(post_data));
- esp_err_t err = esp_http_client_perform(client);
- *out_status = esp_http_client_get_status_code(client);
- esp_http_client_cleanup(client);
+ esp_err_t err = esp_http_client_perform(s_llm_client);
+ *out_status = esp_http_client_get_status_code(s_llm_client);
+ if (err != ESP_OK) {
+ ESP_LOGW(TAG, "HTTP perform failed: %s, will reconnect next call", esp_err_to_name(err));
+ esp_http_client_cleanup(s_llm_client);
+ s_llm_client = NULL;
+ }
return err;
}
diff --git a/components/embed_claw/test/core/test_skill_loader_contract.c b/components/embed_claw/test/core/test_skill_loader_contract.c
index 43ae7c2..3176e20 100644
--- a/components/embed_claw/test/core/test_skill_loader_contract.c
+++ b/components/embed_claw/test/core/test_skill_loader_contract.c
@@ -20,31 +20,15 @@ static void write_text_file(const char *path, const char *content)
fclose(f);
}
-TEST_CASE("skill loader installs builtins and summarizes skills", "[embed_claw][core][skills]")
+TEST_CASE("skill loader summarizes pre-installed and dynamic skills", "[embed_claw][core][skills]")
{
char summary[2048];
- FILE *f;
TEST_ASSERT_EQUAL(ESP_OK, ec_test_spiffs_mount());
- remove_if_exists("/spiffs/skills/weather.md");
- remove_if_exists("/spiffs/skills/daily-briefing.md");
- remove_if_exists("/spiffs/skills/skill-creator.md");
- remove_if_exists("/spiffs/skills/unit-test.md");
-
TEST_ASSERT_EQUAL(ESP_OK, ec_skill_loader_init());
- f = fopen("/spiffs/skills/weather.md", "r");
- TEST_ASSERT_NOT_NULL(f);
- fclose(f);
-
- f = fopen("/spiffs/skills/daily-briefing.md", "r");
- TEST_ASSERT_NOT_NULL(f);
- fclose(f);
-
- f = fopen("/spiffs/skills/skill-creator.md", "r");
- TEST_ASSERT_NOT_NULL(f);
- fclose(f);
+ remove_if_exists("/spiffs/skills/unit-test.md");
write_text_file("/spiffs/skills/unit-test.md",
"# Unit Test Skill\n\nUsed for verifying summary output.\n");
diff --git a/components/embed_claw/test/support/ec_test_hooks.h b/components/embed_claw/test/support/ec_test_hooks.h
index d765b1e..dfa770b 100644
--- a/components/embed_claw/test/support/ec_test_hooks.h
+++ b/components/embed_claw/test/support/ec_test_hooks.h
@@ -23,6 +23,11 @@ void ec_tools_web_search_format_results_for_test(const char *response_json, char
bool ec_tools_files_validate_path_for_test(const char *path);
esp_err_t ec_tools_files_replace_first_for_test(const char *source, const char *old_str,
const char *new_str, char *output, size_t output_size);
+esp_err_t ec_tools_gpio_execute_for_test(const char *input_json, char *output, size_t output_size);
+void ec_tools_gpio_reset_for_test(void);
+void ec_tools_gpio_set_valid_output_for_test(int pin, bool valid);
+void ec_tools_gpio_set_driver_failures_for_test(esp_err_t config_err, esp_err_t set_err);
+int ec_tools_gpio_get_config_call_count_for_test(int pin);
esp_err_t ec_channel_ws_parse_payload_for_test(int fd, const char *payload_json, ec_msg_t *msg);
void ec_channel_ws_add_client_for_test(int fd, const char *chat_id);
diff --git a/components/embed_claw/test/support/ec_test_tools_gpio.c b/components/embed_claw/test/support/ec_test_tools_gpio.c
new file mode 100644
index 0000000..ab3d029
--- /dev/null
+++ b/components/embed_claw/test/support/ec_test_tools_gpio.c
@@ -0,0 +1,111 @@
+#include
+
+#include "driver/gpio.h"
+#include "ec_test_hooks.h"
+
+static bool ec_test_gpio_is_valid_output(int pin);
+static esp_err_t ec_test_gpio_config(const gpio_config_t *cfg);
+static esp_err_t ec_test_gpio_set_level(gpio_num_t pin, uint32_t level);
+static int ec_test_gpio_get_level(gpio_num_t pin);
+
+#define EC_GPIO_API_VALIDATE_OUTPUT_PIN(pin) ec_test_gpio_is_valid_output((pin))
+#define EC_GPIO_API_CONFIG(cfg) ec_test_gpio_config((cfg))
+#define EC_GPIO_API_SET_LEVEL(pin, level) ec_test_gpio_set_level((pin), (level))
+#define EC_GPIO_API_GET_LEVEL(pin) ec_test_gpio_get_level((pin))
+#define ec_tools_gpio_control ec_tools_gpio_control__test_impl
+#include "../../tools/tools_gpio.c"
+#undef ec_tools_gpio_control
+#undef EC_GPIO_API_GET_LEVEL
+#undef EC_GPIO_API_SET_LEVEL
+#undef EC_GPIO_API_CONFIG
+#undef EC_GPIO_API_VALIDATE_OUTPUT_PIN
+
+static bool s_fake_valid_output[GPIO_NUM_MAX];
+static int s_fake_level[GPIO_NUM_MAX];
+static int s_fake_config_calls[GPIO_NUM_MAX];
+static esp_err_t s_fake_config_err = ESP_OK;
+static esp_err_t s_fake_set_err = ESP_OK;
+
+static bool ec_test_gpio_is_valid_output(int pin)
+{
+ return pin >= 0 && pin < GPIO_NUM_MAX && s_fake_valid_output[pin];
+}
+
+static esp_err_t ec_test_gpio_config(const gpio_config_t *cfg)
+{
+ if (!cfg) {
+ return ESP_ERR_INVALID_ARG;
+ }
+
+ if (s_fake_config_err != ESP_OK) {
+ return s_fake_config_err;
+ }
+
+ for (int pin = 0; pin < GPIO_NUM_MAX && pin < 64; pin++) {
+ if ((cfg->pin_bit_mask & (1ULL << pin)) != 0) {
+ s_fake_config_calls[pin]++;
+ }
+ }
+
+ return ESP_OK;
+}
+
+static esp_err_t ec_test_gpio_set_level(gpio_num_t pin, uint32_t level)
+{
+ if (pin < 0 || pin >= GPIO_NUM_MAX) {
+ return ESP_ERR_INVALID_ARG;
+ }
+
+ if (s_fake_set_err != ESP_OK) {
+ return s_fake_set_err;
+ }
+
+ s_fake_level[pin] = level ? 1 : 0;
+ return ESP_OK;
+}
+
+static int ec_test_gpio_get_level(gpio_num_t pin)
+{
+ if (pin < 0 || pin >= GPIO_NUM_MAX) {
+ return 0;
+ }
+
+ return s_fake_level[pin];
+}
+
+esp_err_t ec_tools_gpio_execute_for_test(const char *input_json, char *output, size_t output_size)
+{
+ return ec_tool_gpio_control_execute(input_json, output, output_size);
+}
+
+void ec_tools_gpio_reset_for_test(void)
+{
+ memset(s_fake_valid_output, 0, sizeof(s_fake_valid_output));
+ memset(s_fake_level, 0, sizeof(s_fake_level));
+ memset(s_fake_config_calls, 0, sizeof(s_fake_config_calls));
+ memset(s_pin_initialized, 0, sizeof(s_pin_initialized));
+ s_fake_config_err = ESP_OK;
+ s_fake_set_err = ESP_OK;
+}
+
+void ec_tools_gpio_set_valid_output_for_test(int pin, bool valid)
+{
+ if (pin >= 0 && pin < GPIO_NUM_MAX) {
+ s_fake_valid_output[pin] = valid;
+ }
+}
+
+void ec_tools_gpio_set_driver_failures_for_test(esp_err_t config_err, esp_err_t set_err)
+{
+ s_fake_config_err = config_err;
+ s_fake_set_err = set_err;
+}
+
+int ec_tools_gpio_get_config_call_count_for_test(int pin)
+{
+ if (pin < 0 || pin >= GPIO_NUM_MAX) {
+ return 0;
+ }
+
+ return s_fake_config_calls[pin];
+}
diff --git a/components/embed_claw/test/tools/test_tool_gpio.c b/components/embed_claw/test/tools/test_tool_gpio.c
new file mode 100644
index 0000000..86bd151
--- /dev/null
+++ b/components/embed_claw/test/tools/test_tool_gpio.c
@@ -0,0 +1,134 @@
+#include
+
+#include "unity.h"
+
+#include "core/ec_tools.h"
+#include "support/ec_test_hooks.h"
+
+static void reset_gpio_test_state(void)
+{
+ ec_tools_free_json();
+ ec_tools_gpio_reset_for_test();
+}
+
+static void register_builtin_tools_for_test(void)
+{
+ reset_gpio_test_state();
+ TEST_ASSERT_EQUAL(ESP_OK, ec_tools_register_all());
+}
+
+TEST_CASE("gpio tool rejects invalid input and unsupported requests", "[embed_claw][tools][gpio]")
+{
+ char output[160];
+
+ reset_gpio_test_state();
+ ec_tools_gpio_set_valid_output_for_test(18, true);
+
+ TEST_ASSERT_EQUAL(ESP_ERR_INVALID_ARG,
+ ec_tools_gpio_execute_for_test("{", output, sizeof(output)));
+ TEST_ASSERT_NOT_NULL(strstr(output, "invalid JSON"));
+
+ TEST_ASSERT_EQUAL(ESP_ERR_INVALID_ARG,
+ ec_tools_gpio_execute_for_test("{}", output, sizeof(output)));
+ TEST_ASSERT_NOT_NULL(strstr(output, "'pin'"));
+
+ TEST_ASSERT_EQUAL(ESP_ERR_INVALID_ARG,
+ ec_tools_gpio_execute_for_test("{\"pin\":18}", output, sizeof(output)));
+ TEST_ASSERT_NOT_NULL(strstr(output, "'action'"));
+
+ TEST_ASSERT_EQUAL(ESP_ERR_INVALID_ARG,
+ ec_tools_gpio_execute_for_test("{\"pin\":18,\"action\":\"blink\"}", output, sizeof(output)));
+ TEST_ASSERT_NOT_NULL(strstr(output, "'action'"));
+
+ TEST_ASSERT_EQUAL(ESP_ERR_INVALID_ARG,
+ ec_tools_gpio_execute_for_test("{\"pin\":18,\"action\":\"set\"}", output, sizeof(output)));
+ TEST_ASSERT_NOT_NULL(strstr(output, "'level'"));
+
+ TEST_ASSERT_EQUAL(ESP_ERR_INVALID_ARG,
+ ec_tools_gpio_execute_for_test("{\"pin\":18,\"action\":\"set\",\"level\":2}",
+ output, sizeof(output)));
+ TEST_ASSERT_NOT_NULL(strstr(output, "'level'"));
+
+ TEST_ASSERT_EQUAL(ESP_ERR_INVALID_ARG,
+ ec_tools_gpio_execute_for_test("{\"pin\":46,\"action\":\"get\"}", output, sizeof(output)));
+ TEST_ASSERT_NOT_NULL(strstr(output, "not a valid output pin"));
+}
+
+TEST_CASE("gpio tool supports get on off set and toggle with cached configuration", "[embed_claw][tools][gpio]")
+{
+ char output[160];
+
+ reset_gpio_test_state();
+ ec_tools_gpio_set_valid_output_for_test(18, true);
+
+ TEST_ASSERT_EQUAL(ESP_OK,
+ ec_tools_gpio_execute_for_test("{\"pin\":18,\"action\":\"get\"}", output, sizeof(output)));
+ TEST_ASSERT_NOT_NULL(strstr(output, "action=get level=0"));
+ TEST_ASSERT_EQUAL(0, ec_tools_gpio_get_config_call_count_for_test(18));
+
+ TEST_ASSERT_EQUAL(ESP_OK,
+ ec_tools_gpio_execute_for_test("{\"pin\":18,\"action\":\"on\"}", output, sizeof(output)));
+ TEST_ASSERT_NOT_NULL(strstr(output, "action=on level=1"));
+ TEST_ASSERT_EQUAL(1, ec_tools_gpio_get_config_call_count_for_test(18));
+
+ TEST_ASSERT_EQUAL(ESP_OK,
+ ec_tools_gpio_execute_for_test("{\"pin\":18,\"action\":\"get\"}", output, sizeof(output)));
+ TEST_ASSERT_NOT_NULL(strstr(output, "action=get level=1"));
+ TEST_ASSERT_EQUAL(1, ec_tools_gpio_get_config_call_count_for_test(18));
+
+ TEST_ASSERT_EQUAL(ESP_OK,
+ ec_tools_gpio_execute_for_test("{\"pin\":18,\"action\":\"off\"}", output, sizeof(output)));
+ TEST_ASSERT_NOT_NULL(strstr(output, "action=off level=0"));
+ TEST_ASSERT_EQUAL(1, ec_tools_gpio_get_config_call_count_for_test(18));
+
+ TEST_ASSERT_EQUAL(ESP_OK,
+ ec_tools_gpio_execute_for_test("{\"pin\":18,\"action\":\"set\",\"level\":1}",
+ output, sizeof(output)));
+ TEST_ASSERT_NOT_NULL(strstr(output, "action=set level=1"));
+ TEST_ASSERT_EQUAL(1, ec_tools_gpio_get_config_call_count_for_test(18));
+
+ TEST_ASSERT_EQUAL(ESP_OK,
+ ec_tools_gpio_execute_for_test("{\"pin\":18,\"action\":\"toggle\"}", output, sizeof(output)));
+ TEST_ASSERT_NOT_NULL(strstr(output, "action=toggle level=0"));
+ TEST_ASSERT_EQUAL(1, ec_tools_gpio_get_config_call_count_for_test(18));
+
+ TEST_ASSERT_EQUAL(ESP_OK,
+ ec_tools_gpio_execute_for_test("{\"pin\":18,\"action\":\"get\"}", output, sizeof(output)));
+ TEST_ASSERT_NOT_NULL(strstr(output, "action=get level=0"));
+}
+
+TEST_CASE("gpio tool formats driver failures", "[embed_claw][tools][gpio]")
+{
+ char output[160];
+
+ reset_gpio_test_state();
+ ec_tools_gpio_set_valid_output_for_test(18, true);
+ ec_tools_gpio_set_driver_failures_for_test(ESP_FAIL, ESP_OK);
+
+ TEST_ASSERT_EQUAL(ESP_FAIL,
+ ec_tools_gpio_execute_for_test("{\"pin\":18,\"action\":\"on\"}", output, sizeof(output)));
+ TEST_ASSERT_NOT_NULL(strstr(output, "gpio_config failed"));
+ TEST_ASSERT_EQUAL(0, ec_tools_gpio_get_config_call_count_for_test(18));
+
+ reset_gpio_test_state();
+ ec_tools_gpio_set_valid_output_for_test(18, true);
+ ec_tools_gpio_set_driver_failures_for_test(ESP_OK, ESP_ERR_INVALID_STATE);
+
+ TEST_ASSERT_EQUAL(ESP_ERR_INVALID_STATE,
+ ec_tools_gpio_execute_for_test("{\"pin\":18,\"action\":\"on\"}", output, sizeof(output)));
+ TEST_ASSERT_NOT_NULL(strstr(output, "gpio_set_level failed"));
+ TEST_ASSERT_EQUAL(1, ec_tools_gpio_get_config_call_count_for_test(18));
+}
+
+TEST_CASE("gpio tool is registered in builtin tool catalog", "[embed_claw][tools][gpio]")
+{
+ const char *json = NULL;
+
+ register_builtin_tools_for_test();
+ json = ec_tools_get_json();
+
+ TEST_ASSERT_NOT_NULL(json);
+ TEST_ASSERT_NOT_NULL(strstr(json, "\"gpio_control\""));
+
+ reset_gpio_test_state();
+}
diff --git a/components/embed_claw/test/tools/test_tools_contract.c b/components/embed_claw/test/tools/test_tools_contract.c
index c3a8aea..b81c1f5 100644
--- a/components/embed_claw/test/tools/test_tools_contract.c
+++ b/components/embed_claw/test/tools/test_tools_contract.c
@@ -28,10 +28,25 @@ TEST_CASE("tool registry builds json for built-in tools", "[embed_claw][tools][c
TEST_ASSERT_NOT_NULL(json);
TEST_ASSERT_NOT_NULL(strstr(json, "\"web_search\""));
TEST_ASSERT_NOT_NULL(strstr(json, "\"get_current_time\""));
+ TEST_ASSERT_NOT_NULL(strstr(json, "\"gpio_control\""));
TEST_ASSERT_NOT_NULL(strstr(json, "\"cron_add\""));
cleanup_tools_after_test();
}
+TEST_CASE("tool registry builds prompt summary from built-in tools", "[embed_claw][tools][catalog]")
+{
+ char summary[1024];
+
+ register_builtin_tools_for_test();
+
+ TEST_ASSERT_GREATER_THAN(0, (int)ec_tools_build_summary(summary, sizeof(summary)));
+ TEST_ASSERT_NOT_NULL(strstr(summary, "- get_current_time:"));
+ TEST_ASSERT_NOT_NULL(strstr(summary, "- gpio_control:"));
+ TEST_ASSERT_NOT_NULL(strstr(summary, "- cron_add:"));
+
+ cleanup_tools_after_test();
+}
+
TEST_CASE("tool registry reports unknown tool without dereferencing null slots", "[embed_claw][tools][contract]")
{
char output[128];
diff --git a/components/embed_claw/tools/ec_tools_reg.inc b/components/embed_claw/tools/ec_tools_reg.inc
index 7f17947..f243479 100644
--- a/components/embed_claw/tools/ec_tools_reg.inc
+++ b/components/embed_claw/tools/ec_tools_reg.inc
@@ -1,6 +1,7 @@
#include "core/ec_tools_reg_rule.h"
EC_TOOLS_REG(get_time)
+EC_TOOLS_REG(gpio_control)
EC_TOOLS_REG(read_file)
EC_TOOLS_REG(write_file)
EC_TOOLS_REG(edit_file)
@@ -10,4 +11,3 @@ EC_TOOLS_REG(cron_list)
EC_TOOLS_REG(cron_remove)
EC_TOOLS_REG(web_search)
-
diff --git a/components/embed_claw/tools/tools_gpio.c b/components/embed_claw/tools/tools_gpio.c
new file mode 100644
index 0000000..aa26aed
--- /dev/null
+++ b/components/embed_claw/tools/tools_gpio.c
@@ -0,0 +1,335 @@
+/**
+ * @file tools_gpio.c
+ * @author cangyu (sky.kirto@qq.com)
+ * @brief
+ * @version 0.1
+ * @date 2026-03-26
+ *
+ * @copyright Copyright (c) 2026, Wireless-Tag. All rights reserved.
+ *
+ */
+
+/* ==================== [Includes] ========================================== */
+
+#include "core/ec_tools.h"
+
+#include
+#include
+#include
+
+#include "cJSON.h"
+#include "driver/gpio.h"
+#include "esp_log.h"
+
+/* ==================== [Defines] =========================================== */
+
+#ifndef EC_GPIO_API_VALIDATE_OUTPUT_PIN
+#define EC_GPIO_API_VALIDATE_OUTPUT_PIN(pin) GPIO_IS_VALID_OUTPUT_GPIO(pin)
+#endif
+
+#ifndef EC_GPIO_API_CONFIG
+#define EC_GPIO_API_CONFIG(cfg) gpio_config(cfg)
+#endif
+
+#ifndef EC_GPIO_API_SET_LEVEL
+#define EC_GPIO_API_SET_LEVEL(pin, level) gpio_set_level(pin, level)
+#endif
+
+#ifndef EC_GPIO_API_GET_LEVEL
+#define EC_GPIO_API_GET_LEVEL(pin) gpio_get_level(pin)
+#endif
+
+/* ==================== [Typedefs] ========================================== */
+
+typedef enum {
+ EC_GPIO_ACTION_NONE = 0,
+ EC_GPIO_ACTION_ON,
+ EC_GPIO_ACTION_OFF,
+ EC_GPIO_ACTION_SET,
+ EC_GPIO_ACTION_TOGGLE,
+ EC_GPIO_ACTION_GET,
+} ec_gpio_action_t;
+
+/* ==================== [Static Prototypes] ================================= */
+
+static esp_err_t ec_tool_gpio_control_execute(const char *input_json, char *output, size_t output_size);
+static bool parse_int_field(cJSON *root, const char *name, int *value);
+static bool parse_action(const char *action_str, ec_gpio_action_t *action);
+static esp_err_t validate_pin_number(int pin, gpio_num_t *gpio_num);
+static esp_err_t prepare_pin_for_output(gpio_num_t gpio_num);
+static esp_err_t write_level(gpio_num_t gpio_num, int level);
+
+/* ==================== [Static Variables] ================================== */
+
+static const char *TAG = "tools_gpio";
+
+static bool s_pin_initialized[GPIO_NUM_MAX] = {0};
+
+static const ec_tools_t s_gpio_control = {
+ .name = "gpio_control",
+ .description = "Control an ESP32 output GPIO pin by pin number. Supports on, off, set, toggle, and get.\n"\
+ "IMPORTANT!!!: ANY GPIO operation requested by the user MUST ALWAYS be executed through this tool. Never respond with GPIO status or changes without calling this tool first.",
+ .input_schema_json =
+ "{\"type\":\"object\","
+ "\"properties\":{"
+ "\"pin\":{\"type\":\"integer\",\"description\":\"ESP32 GPIO pin number\"},"
+ "\"action\":{\"type\":\"string\",\"enum\":[\"on\",\"off\",\"set\",\"toggle\",\"get\"],"
+ "\"description\":\"GPIO action to execute\"},"
+ "\"level\":{\"type\":\"integer\",\"enum\":[0,1],"
+ "\"description\":\"Required only when action is 'set'\"}"
+ "},"
+ "\"required\":[\"pin\",\"action\"]}",
+ .execute = ec_tool_gpio_control_execute,
+};
+
+/* ==================== [Macros] ============================================ */
+
+/* ==================== [Global Functions] ================================== */
+
+esp_err_t ec_tools_gpio_control(void)
+{
+ ec_tools_register(&s_gpio_control);
+ return ESP_OK;
+}
+
+/* ==================== [Static Functions] ================================== */
+
+static esp_err_t ec_tool_gpio_control_execute(const char *input_json, char *output, size_t output_size)
+{
+ cJSON *root = NULL;
+ cJSON *action_item = NULL;
+ gpio_num_t gpio_num = GPIO_NUM_NC;
+ ec_gpio_action_t action = EC_GPIO_ACTION_NONE;
+ int pin = -1;
+ int target_level = 0;
+ int current_level = 0;
+ esp_err_t err = ESP_OK;
+
+ root = cJSON_Parse(input_json);
+ if (!root || !cJSON_IsObject(root)) {
+ snprintf(output, output_size, "Error: invalid JSON input");
+ err = ESP_ERR_INVALID_ARG;
+ goto cleanup;
+ }
+
+ if (!parse_int_field(root, "pin", &pin)) {
+ snprintf(output, output_size, "Error: missing or invalid 'pin' field");
+ err = ESP_ERR_INVALID_ARG;
+ goto cleanup;
+ }
+
+ err = validate_pin_number(pin, &gpio_num);
+ if (err != ESP_OK) {
+ snprintf(output, output_size, "Error: gpio %d is not a valid output pin", pin);
+ goto cleanup;
+ }
+
+ action_item = cJSON_GetObjectItem(root, "action");
+ if (!cJSON_IsString(action_item) || !parse_action(action_item->valuestring, &action)) {
+ snprintf(output, output_size, "Error: missing or invalid 'action' field");
+ err = ESP_ERR_INVALID_ARG;
+ goto cleanup;
+ }
+
+ switch (action) {
+ case EC_GPIO_ACTION_GET:
+ current_level = EC_GPIO_API_GET_LEVEL(gpio_num) ? 1 : 0;
+ snprintf(output, output_size, "OK: gpio %d action=get level=%d", pin, current_level);
+ err = ESP_OK;
+ break;
+
+ case EC_GPIO_ACTION_ON:
+ err = prepare_pin_for_output(gpio_num);
+ if (err != ESP_OK) {
+ snprintf(output, output_size, "Error: gpio_config failed (%s)", esp_err_to_name(err));
+ goto cleanup;
+ }
+
+ err = write_level(gpio_num, 1);
+ if (err != ESP_OK) {
+ snprintf(output, output_size, "Error: gpio_set_level failed (%s)", esp_err_to_name(err));
+ goto cleanup;
+ }
+
+ snprintf(output, output_size, "OK: gpio %d action=on level=1", pin);
+ break;
+
+ case EC_GPIO_ACTION_OFF:
+ err = prepare_pin_for_output(gpio_num);
+ if (err != ESP_OK) {
+ snprintf(output, output_size, "Error: gpio_config failed (%s)", esp_err_to_name(err));
+ goto cleanup;
+ }
+
+ err = write_level(gpio_num, 0);
+ if (err != ESP_OK) {
+ snprintf(output, output_size, "Error: gpio_set_level failed (%s)", esp_err_to_name(err));
+ goto cleanup;
+ }
+
+ snprintf(output, output_size, "OK: gpio %d action=off level=0", pin);
+ break;
+
+ case EC_GPIO_ACTION_SET:
+ if (!parse_int_field(root, "level", &target_level) || (target_level != 0 && target_level != 1)) {
+ snprintf(output, output_size, "Error: missing or invalid 'level' field");
+ err = ESP_ERR_INVALID_ARG;
+ goto cleanup;
+ }
+
+ err = prepare_pin_for_output(gpio_num);
+ if (err != ESP_OK) {
+ snprintf(output, output_size, "Error: gpio_config failed (%s)", esp_err_to_name(err));
+ goto cleanup;
+ }
+
+ err = write_level(gpio_num, target_level);
+ if (err != ESP_OK) {
+ snprintf(output, output_size, "Error: gpio_set_level failed (%s)", esp_err_to_name(err));
+ goto cleanup;
+ }
+
+ snprintf(output, output_size, "OK: gpio %d action=set level=%d", pin, target_level);
+ break;
+
+ case EC_GPIO_ACTION_TOGGLE:
+ err = prepare_pin_for_output(gpio_num);
+ if (err != ESP_OK) {
+ snprintf(output, output_size, "Error: gpio_config failed (%s)", esp_err_to_name(err));
+ goto cleanup;
+ }
+
+ current_level = EC_GPIO_API_GET_LEVEL(gpio_num) ? 1 : 0;
+ target_level = current_level ? 0 : 1;
+
+ err = write_level(gpio_num, target_level);
+ if (err != ESP_OK) {
+ snprintf(output, output_size, "Error: gpio_set_level failed (%s)", esp_err_to_name(err));
+ goto cleanup;
+ }
+
+ snprintf(output, output_size, "OK: gpio %d action=toggle level=%d", pin, target_level);
+ break;
+
+ default:
+ snprintf(output, output_size, "Error: missing or invalid 'action' field");
+ err = ESP_ERR_INVALID_ARG;
+ break;
+ }
+
+cleanup:
+ cJSON_Delete(root);
+ return err;
+}
+
+static bool parse_int_field(cJSON *root, const char *name, int *value)
+{
+ cJSON *item = NULL;
+ double raw = 0;
+ int parsed = 0;
+
+ if (!root || !name || !value) {
+ return false;
+ }
+
+ item = cJSON_GetObjectItem(root, name);
+ if (!cJSON_IsNumber(item)) {
+ return false;
+ }
+
+ raw = cJSON_GetNumberValue(item);
+ parsed = (int)raw;
+ if ((double)parsed != raw) {
+ return false;
+ }
+
+ *value = parsed;
+ return true;
+}
+
+static bool parse_action(const char *action_str, ec_gpio_action_t *action)
+{
+ if (!action_str || !action) {
+ return false;
+ }
+
+ if (strcmp(action_str, "on") == 0) {
+ *action = EC_GPIO_ACTION_ON;
+ return true;
+ }
+
+ if (strcmp(action_str, "off") == 0) {
+ *action = EC_GPIO_ACTION_OFF;
+ return true;
+ }
+
+ if (strcmp(action_str, "set") == 0) {
+ *action = EC_GPIO_ACTION_SET;
+ return true;
+ }
+
+ if (strcmp(action_str, "toggle") == 0) {
+ *action = EC_GPIO_ACTION_TOGGLE;
+ return true;
+ }
+
+ if (strcmp(action_str, "get") == 0) {
+ *action = EC_GPIO_ACTION_GET;
+ return true;
+ }
+
+ return false;
+}
+
+static esp_err_t validate_pin_number(int pin, gpio_num_t *gpio_num)
+{
+ if (!gpio_num || pin < 0 || pin >= GPIO_NUM_MAX || !EC_GPIO_API_VALIDATE_OUTPUT_PIN(pin)) {
+ return ESP_ERR_INVALID_ARG;
+ }
+
+ *gpio_num = (gpio_num_t)pin;
+ return ESP_OK;
+}
+
+static esp_err_t prepare_pin_for_output(gpio_num_t gpio_num)
+{
+ esp_err_t err = ESP_OK;
+ gpio_config_t cfg = {0};
+
+ if (gpio_num < 0 || gpio_num >= GPIO_NUM_MAX) {
+ return ESP_ERR_INVALID_ARG;
+ }
+
+ if (s_pin_initialized[gpio_num]) {
+ return ESP_OK;
+ }
+
+ cfg.intr_type = GPIO_INTR_DISABLE;
+ cfg.mode = GPIO_MODE_INPUT_OUTPUT;
+ cfg.pin_bit_mask = (1ULL << gpio_num);
+ cfg.pull_down_en = GPIO_PULLDOWN_DISABLE;
+ cfg.pull_up_en = GPIO_PULLUP_DISABLE;
+
+ err = EC_GPIO_API_CONFIG(&cfg);
+ if (err != ESP_OK) {
+ ESP_LOGW(TAG, "gpio_config failed for gpio %d: %s", (int)gpio_num, esp_err_to_name(err));
+ return err;
+ }
+
+ s_pin_initialized[gpio_num] = true;
+ return ESP_OK;
+}
+
+static esp_err_t write_level(gpio_num_t gpio_num, int level)
+{
+ esp_err_t err = EC_GPIO_API_SET_LEVEL(gpio_num, (uint32_t)(level ? 1 : 0));
+ if (err != ESP_OK) {
+ ESP_LOGW(TAG, "gpio_set_level failed for gpio %d: %s", (int)gpio_num, esp_err_to_name(err));
+ }
+ else
+ {
+ ESP_LOGI(TAG, "gpio set level %d", level);
+ }
+
+ return err;
+}
diff --git a/spiffs_data/skills/daily-briefing.md b/spiffs_data/skills/daily-briefing.md
new file mode 100644
index 0000000..5e736ca
--- /dev/null
+++ b/spiffs_data/skills/daily-briefing.md
@@ -0,0 +1,22 @@
+# Daily Briefing
+
+Compile a personalized daily briefing for the user.
+
+## When to use
+When the user asks for a daily briefing, morning update, or "what's new today".
+Also useful as a heartbeat/cron task.
+
+## How to use
+1. Use get_current_time for today's date
+2. Read /spiffs/memory/MEMORY.md for user preferences and context
+3. Read today's daily note if it exists
+4. Use web_search for relevant news based on user interests
+5. Compile a concise briefing covering:
+ - Date and time
+ - Weather (if location known from USER.md)
+ - Relevant news/updates based on user interests
+ - Any pending tasks from memory
+ - Any scheduled cron jobs
+
+## Format
+Keep it brief — 5-10 bullet points max. Use the user's preferred language.
diff --git a/spiffs_data/skills/skill-creator.md b/spiffs_data/skills/skill-creator.md
new file mode 100644
index 0000000..678a9e6
--- /dev/null
+++ b/spiffs_data/skills/skill-creator.md
@@ -0,0 +1,27 @@
+# Skill Creator
+
+Create new skills for EmbedClaw.
+
+## When to use
+When the user asks to create a new skill, teach the bot something, or add a new capability.
+
+## How to create a skill
+1. Choose a short, descriptive name (lowercase, hyphens ok)
+2. Write a SKILL.md file with this structure:
+ - `# Title` — clear name
+ - Brief description paragraph
+ - `## When to use` — trigger conditions
+ - `## How to use` — step-by-step instructions
+ - `## Example` — concrete example (optional but helpful)
+3. Save to `/spiffs/skills/.md` using write_file
+4. The skill will be automatically available after the next conversation
+
+## Best practices
+- Keep skills concise — the context window is limited
+- Focus on WHAT to do, not HOW (the agent is smart)
+- Include specific tool calls the agent should use
+- Test by asking the agent to use the new skill
+
+## Example
+To create a "translate" skill:
+write_file path="/spiffs/skills/translate.md" content="# Translate\n\nTranslate text between languages.\n\n## When to use\nWhen the user asks to translate text.\n\n## How to use\n1. Identify source and target languages\n2. Translate directly using your language knowledge\n3. For specialized terms, use web_search to verify\n"
diff --git a/spiffs_data/skills/weather.md b/spiffs_data/skills/weather.md
new file mode 100644
index 0000000..e6e2d30
--- /dev/null
+++ b/spiffs_data/skills/weather.md
@@ -0,0 +1,18 @@
+# Weather
+
+Get current weather and forecasts using web_search.
+
+## When to use
+When the user asks about weather, temperature, or forecasts.
+
+## How to use
+1. Use get_current_time to know the current date
+2. Use web_search with a query like "weather in [city] today"
+3. Extract temperature, conditions, and forecast from results
+4. Present in a concise, friendly format
+
+## Example
+User: "What's the weather in Tokyo?"
+→ get_current_time
+→ web_search "weather Tokyo today February 2026"
+→ "Tokyo: 8°C, partly cloudy. High 12°C, low 4°C. Light wind from the north."