Compare commits

...

27 Commits

Author SHA1 Message Date
46e46b0a13 增加项目注释
Some checks failed
Build / idf-build (push) Has been cancelled
2026-03-31 17:56:51 +08:00
crispyberry
bb10ea0149 Merge pull request #152 from IRONICBo/feat/release-spiffs 2026-03-17 12:02:14 +08:00
Asklv
2b68d56c69 feat: include spiffs.bin in release and switch console to USB-JTAG
Add spiffs.bin to CI release artifacts and merged firmware image.
Switch primary console from UART to USB-Serial-JTAG so web serial
monitor can interact with the device directly over USB.
2026-03-16 11:00:00 +08:00
crispyberry
9f17f1d243 Merge pull request #150 from wjc1207/fix/feishu-md
Docs: Fix feishu subscription mode description
2026-03-16 09:58:05 +08:00
wjc1207
07ca630b69 Docs: fix feishu md subscription mode description 2026-03-14 23:13:27 +08:00
crispyberry
5ff0920399 fix: increase SPIFFS_OBJ_NAME_LEN to 64 for long session filenames
Fixes #147 — Feishu open_id produces session filenames that exceed the
default 32-byte SPIFFS object name limit, causing "Cannot open session
file" errors.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-13 20:27:21 +08:00
crispyberry
85459fb210 Merge pull request #127 from IRONICBo/feat/gpio-skill-support 2026-03-13 12:13:47 +08:00
crispyberry
974ec7f974 Merge pull request #136 from memovai/docs/wifi-onboarding-ap-guide 2026-03-11 12:08:53 +08:00
crispyberry
ee1002b160 Merge pull request #131 from memovai/feat/wifi-onboarding-captive-portal 2026-03-11 12:08:41 +08:00
crispyberry
6ee8aff00f Merge pull request #137 from wjc1207/feat/star-history 2026-03-10 13:45:09 +08:00
wjc1207
73df856b06 Docs: Fix star history chart theme again 2026-03-09 22:48:12 +08:00
Asklv
c1f13fa38c feat: allow lcd and rgb pins 2026-03-09 00:23:03 +08:00
Asklv
37c0b0d6ee fix: protect usb console pins 2026-03-09 00:23:03 +08:00
Asklv
05c23b2f65 feat: add onboarding guide links 2026-03-08 23:13:04 +08:00
Asklv
4d5d2e1e5a feat: link onboarding guide in docs 2026-03-08 23:13:01 +08:00
Asklv
8eb048d681 feat: add onboarding ap guide 2026-03-08 23:12:55 +08:00
Asklv
c0ea5f22fb feat: align portal NVS keys 2026-03-08 23:04:24 +08:00
Asklv
f0e26fd338 feat: prefill portal settings 2026-03-08 23:04:19 +08:00
Asklv
163f946e50 feat: keep admin portal online 2026-03-08 23:04:14 +08:00
Asklv
16eb01b49a fix(build): add esp_driver_gpio dependency for GPIO tool 2026-03-08 12:32:37 +08:00
crispyberry
6c283553f9 feat: add WiFi onboarding captive portal
When no WiFi credentials are configured or connection fails, the device
automatically starts a soft AP (MimiClaw-XXXX) with a captive portal.
Users can configure WiFi, LLM, Telegram, Feishu, Proxy and Search
settings via a mobile-friendly web page, then the device restarts and
connects with the new credentials.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-08 12:15:26 +08:00
Asklv
52f3bee043 feat: add GPIO config section to mimi_config.h 2026-03-07 23:51:19 +08:00
Asklv
cf9a1c11ae feat: add built-in gpio-control skill for switch confirmation 2026-03-07 23:51:19 +08:00
Asklv
22ac11c9fd feat: add GPIO tool descriptions to agent system prompt 2026-03-07 23:50:32 +08:00
Asklv
d30b3611f3 feat: register GPIO tools in registry and build system 2026-03-07 23:50:32 +08:00
Asklv
46dc186a80 feat: implement gpio_write, gpio_read, gpio_read_all handlers 2026-03-07 23:50:32 +08:00
Asklv
e278264133 feat: add GPIO policy module and tool header 2026-03-07 23:50:32 +08:00
28 changed files with 1930 additions and 49 deletions

View File

@@ -28,22 +28,24 @@ jobs:
. $IDF_PATH/export.sh
VERSION="${GITHUB_REF_NAME}"
# Generate merged firmware for single-command flashing
# Generate merged firmware for single-command flashing (includes SPIFFS)
esptool.py --chip esp32s3 merge_bin \
--flash_mode qio \
--flash_size 16MB \
--flash_freq 80m \
-o "mimiclaw-full-${VERSION}.bin" \
0x0 build/bootloader/bootloader.bin \
0x8000 build/partition_table/partition-table.bin \
0xf000 build/ota_data_initial.bin \
0x20000 build/mimiclaw.bin
0x0 build/bootloader/bootloader.bin \
0x8000 build/partition_table/partition-table.bin \
0xf000 build/ota_data_initial.bin \
0x20000 build/mimiclaw.bin \
0x420000 build/spiffs.bin
# Copy individual binaries with version suffix
cp build/mimiclaw.bin "mimiclaw-${VERSION}.bin"
cp build/bootloader/bootloader.bin "bootloader-${VERSION}.bin"
cp build/partition_table/partition-table.bin "partition-table-${VERSION}.bin"
cp build/ota_data_initial.bin "ota_data_initial-${VERSION}.bin"
cp build/spiffs.bin "spiffs-${VERSION}.bin"
- name: Create Release
uses: softprops/action-gh-release@v2
@@ -64,10 +66,11 @@ jobs:
```bash
esptool.py --chip esp32s3 -b 460800 write_flash \
0x0 bootloader-${{ github.ref_name }}.bin \
0x8000 partition-table-${{ github.ref_name }}.bin \
0xf000 ota_data_initial-${{ github.ref_name }}.bin \
0x20000 mimiclaw-${{ github.ref_name }}.bin
0x0 bootloader-${{ github.ref_name }}.bin \
0x8000 partition-table-${{ github.ref_name }}.bin \
0xf000 ota_data_initial-${{ github.ref_name }}.bin \
0x20000 mimiclaw-${{ github.ref_name }}.bin \
0x420000 spiffs-${{ github.ref_name }}.bin
```
### 3. OTA update (for devices already running MimiClaw)
@@ -90,3 +93,4 @@ jobs:
bootloader-${{ github.ref_name }}.bin
partition-table-${{ github.ref_name }}.bin
ota_data_initial-${{ github.ref_name }}.bin
spiffs-${{ github.ref_name }}.bin

143
AGENTS.md Normal file
View File

@@ -0,0 +1,143 @@
# MimiClaw AI Agent - 开发指南
## 项目概述
MimiClaw 是一个运行在 ESP32-S3 上的 AI 助手,使用纯 C 语言编写。用户通过 Telegram 与之交互,设备连接 WiFi 后,将消息传递给 LLM大语言模型进行处理并支持工具调用。
## 项目结构
```
mimiclaw/
├── main/ # 主应用程序代码
│ ├── agent/ # 代理循环(核心逻辑)
│ │ ├── agent_loop.c # 主代理循环,处理消息和工具调用
│ │ └── context_builder.c # 构建上下文(系统提示、记忆等)
│ ├── llm/ # LLM 代理
│ │ ├── llm_proxy.c # 处理与 LLM API 的通信
│ │ └── llm_proxy.h # LLM 代理的头文件
│ ├── cli/ # 串口命令行界面
│ │ └── serial_cli.c # 处理运行时配置命令
│ ├── channels/ # 输入/输出通道
│ │ ├── telegram/ # Telegram 机器人集成
│ │ └── feishu/ # 飞书机器人集成
│ ├── tools/ # LLM 可调用的工具
│ ├── memory/ # 记忆和会话管理
│ ├── proxy/ # HTTP 代理支持
│ ├── cron/ # 定时任务调度
│ ├── heartbeat/ # 心跳服务
│ ├── gateway/ # WebSocket 网关
│ ├── onboard/ # WiFi 配置门户
│ ├── skills/ # 技能加载器
│ ├── mimi_config.h # 全局配置定义
│ ├── mimi_secrets.h # 构建时密钥(需用户创建)
│ └── mimi_secrets.h.example # 密钥模板
├── docs/ # 文档
├── scripts/ # 构建和设置脚本
├── CMakeLists.txt # 顶层 CMake 文件
└── sdkconfig.defaults # ESP-IDF 默认配置
```
## 核心系统
### 1. 配置系统
- **构建时配置**:在 `main/mimi_secrets.h` 中定义(从 `.example` 复制)
- **运行时配置**:通过串口 CLI 命令设置,存储在 NVS 中,优先级高于构建时配置
- **关键配置项**
- `MIMI_SECRET_WIFI_SSID/PASS`WiFi 凭证
- `MIMI_SECRET_TG_TOKEN`Telegram 机器人令牌
- `MIMI_SECRET_API_KEY`LLM API 密钥
- `MIMI_SECRET_MODEL_PROVIDER`:模型提供商("anthropic" 或 "openai"
- `MIMI_SECRET_MODEL`:模型名称
### 2. LLM 集成
- **当前支持**Anthropic (Claude) 和 OpenAI (GPT)
- **提供商切换**:通过 `MIMI_SECRET_MODEL_PROVIDER` 或 CLI 命令 `set_model_provider <provider>`
- **代码路径**`main/llm/llm_proxy.c`
- **关键函数**
- `llm_proxy_init()`:初始化,从 NVS 加载配置
- `llm_chat_tools()`:发送聊天请求,支持工具调用
- `provider_is_openai()`:检查是否为 OpenAI 提供商
### 3. 代理循环
- **代码路径**`main/agent/agent_loop.c`
- **工作流程**
1. 接收消息
2. 构建上下文(系统提示、记忆、会话历史)
3. 调用 LLM支持工具调用
4. 处理工具调用结果
5. 返回响应
### 4. 工具系统
- 工具在 `main/tools/` 中定义
- 工具注册在 `tool_registry.c`
- 支持的工具:`web_search``get_current_time``cron_add/list/remove`
## 构建和烧录
### 前提条件
- ESP-IDF v5.5+ 已安装
- ESP32-S3 开发板16MB flash, 8MB PSRAM
### 步骤
```bash
# 设置目标芯片
idf.py set-target esp32s3
# 配置(首次需要)
cp main/mimi_secrets.h.example main/mimi_secrets.h
# 编辑 mimi_secrets.h 填写 WiFi、Telegram、LLM 密钥
# 清理构建(修改配置后必须执行)
idf.py fullclean && idf.py build
# 烧录(替换 PORT 为实际串口)
idf.py -p PORT flash monitor
```
## 串口 CLI 命令
连接到 UARTCOM端口后可用的配置命令
```
wifi_set <SSID> <Password> # 设置 WiFi 凭证
set_tg_token <Token> # 设置 Telegram 机器人令牌
set_api_key <Key> # 设置 LLM API 密钥
set_model_provider <Provider># 设置提供商anthropic/openai
set_model <Model> # 设置模型名称
config_show # 显示当前配置(脱敏)
config_reset # 重置为构建时配置
```
## 开发注意事项
1. **内存限制**ESP32-S3 内存有限,使用 `MALLOC_CAP_SPIRAM` 分配大内存
2. **堆栈大小**:任务堆栈在 `mimi_config.h` 中定义
3. **日志**:使用 `ESP_LOG` 宏,标签在每个文件中定义
4. **错误处理**:使用 `esp_err_t` 返回码
5. **JSON 处理**:使用 cJSON 库
## 调试技巧
- 查看日志:`idf.py -p PORT monitor`
- 内存状态:串口 CLI 命令 `heap_info`
- 会话列表:`session_list`
- 记忆内容:`memory_read`
## 扩展指南
### 添加新的 LLM 提供商
1.`llm_proxy.c` 中添加提供商检查函数
2. 添加新的 API URL 和主机名
3. 如需要,添加特定的请求头和消息格式转换
4. 更新 `mimi_config.h` 中的默认配置
### 添加新工具
1.`tools/` 目录创建新文件
2. 实现工具函数
3.`tool_registry.c` 中注册工具
4. 定义工具的 JSON Schema
### 添加新通道
1.`channels/` 目录创建新子目录
2. 实现通道初始化和消息收发
3. 在 `mimi.c` 中初始化新通道

View File

@@ -291,6 +291,7 @@ Technical details live in the `docs/` folder:
- **[docs/ARCHITECTURE.md](docs/ARCHITECTURE.md)** — system design, module map, task layout, memory budget, protocols, flash partitions
- **[docs/TODO.md](docs/TODO.md)** — feature gap tracker and roadmap
- **[docs/WIFI_ONBOARDING_AP.md](docs/WIFI_ONBOARDING_AP.md)** — how the local `MimiClaw-XXXX` onboarding/admin AP flow works
- **[docs/tool-setup/](docs/tool-setup/README.md)** — configuration guides for external service integrations (Tavily, etc.)
## Contributing
@@ -315,4 +316,10 @@ Inspired by [OpenClaw](https://github.com/openclaw/openclaw) and [Nanobot](https
## Star History
[![Star History Chart](https://api.star-history.com/svg?repos=memovai/mimiclaw&type=Date)](https://star-history.com/#memovai/mimiclaw&Date)
<a href="https://www.star-history.com/?repos=memovai%2Fmimiclaw&type=date&legend=top-left">
<picture>
<source media="(prefers-color-scheme: dark)" srcset="https://api.star-history.com/image?repos=memovai/mimiclaw&type=date&theme=dark&legend=top-left" />
<source media="(prefers-color-scheme: light)" srcset="https://api.star-history.com/image?repos=memovai/mimiclaw&type=date&legend=top-left" />
<img alt="Star History Chart" src="https://api.star-history.com/image?repos=memovai/mimiclaw&type=date&legend=top-left" />
</picture>
</a>

View File

@@ -306,6 +306,7 @@ MimiClaw 内置 cron 调度器,让 AI 可以自主安排任务。LLM 可以通
- **[docs/ARCHITECTURE.md](docs/ARCHITECTURE.md)** — 系统设计、模块划分、任务布局、内存分配、协议、Flash 分区
- **[docs/TODO.md](docs/TODO.md)** — 功能差距和路线图
- **[docs/WIFI_ONBOARDING_AP.md](docs/WIFI_ONBOARDING_AP.md)** — 说明本地 `MimiClaw-XXXX` onboarding / 管理热点的使用方式
- **[docs/im-integration/](docs/im-integration/README.md)** — IM 通道集成指南(飞书等)
## 贡献

View File

@@ -291,6 +291,7 @@ MimiClawにはcronスケジューラが内蔵されており、AIが自律的に
- **[docs/ARCHITECTURE.md](docs/ARCHITECTURE.md)** — システム設計、モジュール構成、タスクレイアウト、メモリバジェット、プロトコル、Flashパーティション
- **[docs/TODO.md](docs/TODO.md)** — 機能ギャップとロードマップ
- **[docs/WIFI_ONBOARDING_AP.md](docs/WIFI_ONBOARDING_AP.md)** — ローカル `MimiClaw-XXXX` onboarding / 管理アクセスポイントの使い方
- **[docs/im-integration/](docs/im-integration/README.md)** — IMチャネル統合ガイドFeishuなど
## 貢献

229
changelog.md Normal file
View File

@@ -0,0 +1,229 @@
# 变更日志:增加国内大模型厂商接入
## 版本信息
- **版本**v1.1.0(计划)
- **日期**2026-03-31
- **状态**:计划中
## 功能概述
为 MimiClaw 项目增加国内大模型厂商的接入支持,包括:
1. **硅基流动** (SiliconFlow) - 提供免费模型和多种高性能大模型
2. **火山方舟** (Volcengine Ark) - 字节跳动豆包模型系列
## 实施计划
### 阶段一准备与设计1-2天
#### 1.1 详细API调研
- [ ] 研究硅基流动API文档确认
- 具体的Base URL和端点
- 认证方式API Key格式
- 支持的模型列表和ID格式
- 工具调用兼容性
- 速率限制和配额
- [ ] 研究火山方舟API文档确认
- 具体的Base URL和端点
- 认证方式API Key格式
- 支持的模型列表和ID格式
- 工具调用兼容性
- 速率限制和配额
#### 1.2 架构设计
- [ ] 设计提供商扩展机制
- [ ] 确定配置管理方案
- [ ] 设计命令行接口扩展
- [ ] 评估内存影响
### 阶段二核心实现3-5天
#### 2.1 配置系统扩展
- [ ] 修改 `mimi_secrets.h.example` 添加新配置项:
```c
/* 国内大模型厂商配置 */
#define MIMI_SECRET_SILICONFLOW_API_KEY ""
#define MIMI_SECRET_SILICONFLOW_BASE_URL "https://api.siliconflow.cn/v1"
#define MIMI_SECRET_VOLCENGINE_API_KEY ""
#define MIMI_SECRET_VOLCENGINE_BASE_URL "https://ark.cn-beijing.volces.com/api/v3"
```
- [ ] 更新 `mimi_config.h` 添加相关默认值
#### 2.2 LLM代理扩展
- [ ] 修改 `llm_proxy.c` 支持新的提供商:
- 添加 `provider_is_siliconflow()` 函数
- 添加 `provider_is_volcengine()` 函数
- 扩展 `llm_api_url()` 函数支持新提供商
- 扩展 `llm_api_host()` 函数支持新提供商
- 扩展 `llm_api_path()` 函数支持新提供商
- [ ] 添加Base URL配置支持
```c
static char s_siliconflow_base_url[256] = "https://api.siliconflow.cn/v1";
static char s_volcengine_base_url[256] = "https://ark.cn-beijing.volces.com/api/v3";
```
- [ ] 修改HTTP请求头设置逻辑
- 硅基流动使用Bearer Token认证
- 火山方舟使用Bearer Token认证假设与OpenAI兼容
- [ ] 添加模型名称转换逻辑(如果需要)
#### 2.3 命令行界面扩展
- [ ] 在 `serial_cli.c` 添加新命令:
- `set_siliconflow_key <key>`设置硅基流动API密钥
- `set_siliconflow_url <url>`设置硅基流动Base URL
- `set_volcengine_key <key>`设置火山方舟API密钥
- `set_volcengine_url <url>`设置火山方舟Base URL
- [ ] 更新现有命令的帮助信息
- [ ] 更新 `config_show` 命令显示新配置
#### 2.4 提供商切换机制
- [ ] 修改 `set_model_provider` 命令支持新提供商:
- 支持值:`anthropic`, `openai`, `siliconflow`, `volcengine`
- [ ] 更新NVS存储键名
- 可能需要扩展 `MIMI_NVS_KEY_PROVIDER` 支持更多值
### 阶段三测试与优化2-3天
#### 3.1 功能测试
- [ ] 单元测试:
- 提供商检测函数测试
- API URL生成测试
- 请求头设置测试
- [ ] 集成测试:
- 硅基流动API连接测试
- 火山方舟API连接测试
- 工具调用功能测试
- 提供商切换测试
#### 3.2 性能优化
- [ ] 内存使用优化:
- 评估新增变量对内存的影响
- 优化字符串存储大小
- [ ] 网络性能:
- 测试国内网络环境下的连接稳定性
- 优化超时设置
#### 3.3 错误处理
- [ ] 添加详细的错误日志
- [ ] 处理API特定的错误响应
- [ ] 添加重试机制(如果需要)
### 阶段四文档与发布1天
#### 4.1 文档更新
- [ ] 更新 `README.md` 添加新功能说明
- [ ] 更新 `mimi_secrets.h.example` 添加配置示例
- [ ] 创建国内大模型厂商配置指南
- [ ] 更新串口CLI命令文档
#### 4.2 发布准备
- [ ] 代码审查
- [ ] 最终测试
- [ ] 创建发布标签
## 技术细节
### 提供商检测逻辑
```c
// 在 llm_proxy.c 中添加
static bool provider_is_siliconflow(void) {
return strcmp(s_provider, "siliconflow") == 0;
}
static bool provider_is_volcengine(void) {
return strcmp(s_provider, "volcengine") == 0;
}
```
### API URL 配置
```c
// 扩展 llm_api_url 函数
static const char *llm_api_url(void) {
if (provider_is_openai()) {
return MIMI_OPENAI_API_URL;
} else if (provider_is_siliconflow()) {
return s_siliconflow_base_url;
} else if (provider_is_volcengine()) {
return s_volcengine_base_url;
} else {
return MIMI_LLM_API_URL; // Anthropic
}
}
```
### 请求头设置
```c
// 扩展 HTTP 请求头设置
if (provider_is_openai() || provider_is_siliconflow() || provider_is_volcengine()) {
// OpenAI兼容的Bearer Token认证
if (s_api_key[0]) {
char auth[LLM_API_KEY_MAX_LEN + 16];
snprintf(auth, sizeof(auth), "Bearer %s", s_api_key);
esp_http_client_set_header(client, "Authorization", auth);
}
} else {
// Anthropic的x-api-key认证
esp_http_client_set_header(client, "x-api-key", s_api_key);
esp_http_client_set_header(client, "anthropic-version", MIMI_LLM_API_VERSION);
}
```
## 风险评估与缓解
### 风险1API兼容性问题
- **风险**国内厂商的API可能与OpenAI有细微差异
- **缓解**:详细测试,添加兼容性处理代码
### 风险2内存限制
- **风险**新增配置可能超出ESP32-S3内存限制
- **缓解**:优化字符串存储,使用固定大小数组
### 风险3网络连接问题
- **风险**国内网络环境可能影响API调用
- **缓解**:添加重试机制,优化超时设置
### 风险4认证安全
- **风险**API密钥存储和传输安全
- **缓解**使用现有的NVS加密存储确保安全传输
## 预期成果
1. **功能完成**:支持硅基流动和火山方舟两个国内大模型厂商
2. **配置灵活**:用户可以通过命令行或配置文件灵活配置
3. **向后兼容**不影响现有的Anthropic和OpenAI功能
4. **文档完整**:提供完整的配置和使用文档
## 时间估算
- **总时间**7-11个工作日
- **阶段一**1-2天
- **阶段二**3-5天
- **阶段三**2-3天
- **阶段四**1天
## 依赖项
1. **外部依赖**
- 硅基流动API访问权限
- 火山方舟API访问权限
- 稳定的网络连接
2. **内部依赖**
- 现有的LLM代理架构
- 配置管理系统
- 命令行界面系统
## 成功标准
1. 可以成功连接硅基流动API并获取响应
2. 可以成功连接火山方舟API并获取响应
3. 工具调用功能在两个新提供商上正常工作
4. 提供商切换功能正常
5. 内存使用在可接受范围内
6. 所有现有功能保持正常

100
docs/WIFI_ONBOARDING_AP.md Normal file
View File

@@ -0,0 +1,100 @@
# Wi-Fi AP Onboarding Guide
This guide documents the Wi-Fi onboarding flow for firmware builds that include the MimiClaw onboarding portal.
## What It Does
When onboarding is enabled, MimiClaw can expose a local Wi-Fi access point such as `MimiClaw-XXXX` and serve a configuration page at `http://192.168.4.1`.
Typical uses:
- first-time Wi-Fi setup without editing firmware
- updating saved Wi-Fi, model, proxy, or bot credentials later
- recovering a device that can no longer join the previous router
## Requirements
- a firmware build with the Wi-Fi onboarding portal enabled
- an ESP32-S3 board powered on and booted normally
- a phone or laptop that can join the temporary `MimiClaw-XXXX` network
## First-Time Setup
1. Power on the device.
2. Wait for the onboarding hotspot `MimiClaw-XXXX` to appear.
3. Join that hotspot from your phone or laptop.
4. Open `http://192.168.4.1` if the captive page does not open automatically.
5. Fill in at least:
- `SSID`
- `Password`
6. Add optional settings if needed:
- `API Key`
- `Model`
- `Provider`
- `Telegram`
- `Feishu`
- `Proxy`
- `Search`
7. Click `Save & Restart`.
8. Wait for the device to reboot and join your normal Wi-Fi network.
## Updating Settings Later
If the firmware keeps the admin AP online after normal Wi-Fi connection, you can reconnect to `MimiClaw-XXXX` later and open `http://192.168.4.1` again.
The page should prefill the currently effective configuration so you can edit only the fields you want to change.
## Config Priority
The onboarding page follows the same config priority as the firmware:
1. saved NVS values
2. build-time defaults from `main/mimi_secrets.h`
That means:
- if a field exists in NVS, it overrides the build-time default
- if you clear a saved field and reboot, the device falls back to `main/mimi_secrets.h`
- if both NVS and build-time values are empty, the field stays empty
## Clearing Saved Values
On firmware versions that support clearing fields from the portal:
- leaving a field blank and saving removes the corresponding NVS key
- after reboot, the page shows the build-time fallback if one exists
If you need to wipe all saved runtime settings, use:
```text
mimi> config_reset
```
## Troubleshooting
### No `MimiClaw-XXXX` hotspot appears
- verify that the running firmware actually includes the onboarding portal
- if the device already connects successfully and does not keep the admin AP online, clear Wi-Fi config and reboot
- confirm the board has finished booting before scanning for Wi-Fi
### The page still shows old values
- refresh the page manually
- reconnect to the AP and open `http://192.168.4.1` again
- if needed, restart the browser once to clear stale captive portal state
### The build-time provider/model do not show up
If the device already has `model` or `provider` saved in NVS, the saved values win over `main/mimi_secrets.h`.
To return to build-time defaults:
- clear those fields in the portal and save, or
- run `config_reset`
## Notes
- The onboarding AP is typically local-only and intended for nearby configuration.
- Current onboarding implementations may use an open AP for simplicity, so avoid leaving it exposed longer than necessary.
- If your deployment needs stronger local protection, add an AP password before using the flow in production.

View File

@@ -2,6 +2,10 @@
Configuration guides for MimiClaw's external service integrations.
Related local setup guide:
- [Wi-Fi AP Onboarding Guide](../WIFI_ONBOARDING_AP.md) — configure firmware builds that expose the local `MimiClaw-XXXX` onboarding/admin access point
## Guides
| Guide | Service | Description |

View File

@@ -20,11 +20,14 @@ idf_component_register(
"tools/tool_web_search.c"
"tools/tool_get_time.c"
"tools/tool_files.c"
"tools/tool_gpio.c"
"tools/gpio_policy.c"
"skills/skill_loader.c"
"onboard/wifi_onboard.c"
INCLUDE_DIRS
"."
REQUIRES
nvs_flash esp_wifi esp_netif esp_http_client esp_http_server
esp_https_ota esp_event json spiffs console vfs app_update esp-tls
esp_timer esp_websocket_client
esp_timer esp_websocket_client esp_driver_gpio
)

View File

@@ -46,8 +46,15 @@ esp_err_t context_build_system_prompt(char *buf, size_t size)
"- list_dir: List files, optionally filter by prefix.\n"
"- cron_add: Schedule a recurring or one-shot task. The message will trigger an agent turn when the job fires.\n"
"- cron_list: List all scheduled cron jobs.\n"
"- cron_remove: Remove a scheduled cron job by ID.\n\n"
"- cron_remove: Remove a scheduled cron job by ID.\n"
"- gpio_write: Set a GPIO pin HIGH or LOW. Use for controlling LEDs, relays, and digital outputs.\n"
"- gpio_read: Read a single GPIO pin state (HIGH or LOW). Use for checking switches, buttons, sensors.\n"
"- gpio_read_all: Read all allowed GPIO pins at once. Good for getting a full status overview.\n\n"
"When using cron_add for Telegram delivery, always set channel='telegram' and a valid numeric chat_id.\n\n"
"## GPIO\n"
"You can control hardware GPIO pins on the ESP32-S3. Use gpio_read to check switch/sensor states "
"(digital input confirmation), and gpio_write to control outputs. Pin range is validated by policy — "
"only allowed pins can be accessed. When asked about switch states or digital I/O, use these tools.\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"

View File

@@ -5,7 +5,7 @@ This directory contains the Feishu bot integration for MimiClaw.
## Features
- Send text messages to Feishu chats
- Receive messages via webhook (HTTP event subscription)
- Receive messages via WebSocket persistent connection (long-connection mode)
- Automatic message chunking (4096 chars per message)
- Tenant access token management with auto-refresh
- Message deduplication
@@ -46,17 +46,17 @@ mimi> set_feishu_creds cli_xxxxxxxxxxxxxx xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx
- `im:message` - Send and receive messages
- `im:message:send_as_bot` - Send messages as bot
4. Configure Event Subscription:
- Request URL: `http://<ESP32_IP>:18790/feishu/events`
- Set subscription mode to **Persistent connection** (长连接)
- Subscribe to: `im.message.receive_v1`
5. The ESP32 will auto-respond to the URL verification challenge
5. The ESP32 will connect to Feishu automatically on boot
## Architecture
```
Feishu Server
|
v (HTTP POST /feishu/events)
[ESP32 Webhook Server :18790]
Feishu Server (wss://open.feishu.cn)
^
| (WebSocket persistent connection, ESP32 initiates)
[feishu_ws_task]
|
v (message_bus_push_inbound)
[Message Bus] --> [Agent Loop] --> [Message Bus]
@@ -73,7 +73,7 @@ Feishu API
| Function | Description |
|----------|-------------|
| `feishu_bot_init()` | Load credentials from NVS/build-time |
| `feishu_bot_start()` | Start webhook HTTP server |
| `feishu_bot_start()` | Start WebSocket persistent connection task |
| `feishu_send_message(chat_id, text)` | Send text message |
| `feishu_reply_message(message_id, text)` | Reply to a specific message |
| `feishu_set_credentials(app_id, secret)` | Save credentials to NVS |
@@ -82,4 +82,4 @@ Feishu API
- [Feishu Open Platform Docs](https://open.feishu.cn/document/home/index)
- [Message API](https://open.feishu.cn/document/server-docs/im-v1/message/create)
- [Event Subscription](https://open.feishu.cn/document/server-docs/event-subscription/event-subscription-guide)
- [Long-connection Event Subscription](https://open.feishu.cn/document/server-docs/event-subscription/long-connection)

View File

@@ -528,6 +528,32 @@ static void print_config(const char *label, const char *ns, const char *key,
}
}
static void print_config_u16(const char *label, const char *ns, const char *key,
const char *build_val)
{
char nvs_val[16] = {0};
const char *source = "not set";
const char *display = "(empty)";
nvs_handle_t nvs;
if (nvs_open(ns, NVS_READONLY, &nvs) == ESP_OK) {
uint16_t value = 0;
if (nvs_get_u16(nvs, key, &value) == ESP_OK && value > 0) {
snprintf(nvs_val, sizeof(nvs_val), "%u", (unsigned)value);
source = "NVS";
display = nvs_val;
}
nvs_close(nvs);
}
if (strcmp(source, "not set") == 0 && build_val[0] != '\0') {
source = "build";
display = build_val;
}
printf(" %-14s: %s [%s]\n", label, display, source);
}
static int cmd_config_show(int argc, char **argv)
{
printf("=== Current Configuration ===\n");
@@ -538,7 +564,7 @@ static int cmd_config_show(int argc, char **argv)
print_config("Model", MIMI_NVS_LLM, MIMI_NVS_KEY_MODEL, MIMI_SECRET_MODEL, false);
print_config("Provider", MIMI_NVS_LLM, MIMI_NVS_KEY_PROVIDER, MIMI_SECRET_MODEL_PROVIDER, false);
print_config("Proxy Host", MIMI_NVS_PROXY, MIMI_NVS_KEY_PROXY_HOST, MIMI_SECRET_PROXY_HOST, false);
print_config("Proxy Port", MIMI_NVS_PROXY, MIMI_NVS_KEY_PROXY_PORT, MIMI_SECRET_PROXY_PORT, false);
print_config_u16("Proxy Port", MIMI_NVS_PROXY, MIMI_NVS_KEY_PROXY_PORT, MIMI_SECRET_PROXY_PORT);
print_config("Search Key", MIMI_NVS_SEARCH, MIMI_NVS_KEY_API_KEY, MIMI_SECRET_SEARCH_KEY, true);
print_config("Tavily Key", MIMI_NVS_SEARCH, MIMI_NVS_KEY_TAVILY_KEY, MIMI_SECRET_TAVILY_KEY, true);
printf("=============================\n");

View File

@@ -25,6 +25,7 @@
#include "cron/cron_service.h"
#include "heartbeat/heartbeat.h"
#include "skills/skill_loader.h"
#include "onboard/wifi_onboard.h"
static const char *TAG = "mimi";
@@ -141,34 +142,48 @@ void app_main(void)
/* Start WiFi */
esp_err_t wifi_err = wifi_manager_start();
bool wifi_ok = false;
if (wifi_err == ESP_OK) {
ESP_LOGI(TAG, "Scanning nearby APs on boot...");
wifi_manager_scan_and_print();
ESP_LOGI(TAG, "Waiting for WiFi connection...");
if (wifi_manager_wait_connected(30000) == ESP_OK) {
wifi_ok = true;
ESP_LOGI(TAG, "WiFi connected: %s", wifi_manager_get_ip());
/* Outbound dispatch task should start first to avoid dropping early replies. */
ESP_ERROR_CHECK((xTaskCreatePinnedToCore(
outbound_dispatch_task, "outbound",
MIMI_OUTBOUND_STACK, NULL,
MIMI_OUTBOUND_PRIO, NULL, MIMI_OUTBOUND_CORE) == pdPASS)
? ESP_OK : ESP_FAIL);
/* Start network-dependent services */
ESP_ERROR_CHECK(agent_loop_start());
ESP_ERROR_CHECK(telegram_bot_start());
ESP_ERROR_CHECK(feishu_bot_start());
cron_service_start();
heartbeat_start();
ESP_ERROR_CHECK(ws_server_start());
ESP_LOGI(TAG, "All services started!");
} else {
ESP_LOGW(TAG, "WiFi connection timeout. Check MIMI_SECRET_WIFI_SSID in mimi_secrets.h");
ESP_LOGW(TAG, "WiFi connection timeout");
}
} else {
ESP_LOGW(TAG, "No WiFi credentials. Set MIMI_SECRET_WIFI_SSID in mimi_secrets.h");
ESP_LOGW(TAG, "No WiFi credentials configured");
}
if (!wifi_ok) {
ESP_LOGW(TAG, "Entering WiFi onboarding mode...");
wifi_onboard_start(WIFI_ONBOARD_MODE_CAPTIVE); /* blocks, restarts on success */
return; /* unreachable */
}
if (wifi_onboard_start(WIFI_ONBOARD_MODE_ADMIN) != ESP_OK) {
ESP_LOGW(TAG, "Local admin portal unavailable; continuing without config hotspot");
}
{
/* Outbound dispatch task should start first to avoid dropping early replies. */
ESP_ERROR_CHECK((xTaskCreatePinnedToCore(
outbound_dispatch_task, "outbound",
MIMI_OUTBOUND_STACK, NULL,
MIMI_OUTBOUND_PRIO, NULL, MIMI_OUTBOUND_CORE) == pdPASS)
? ESP_OK : ESP_FAIL);
/* Start network-dependent services */
ESP_ERROR_CHECK(agent_loop_start());
ESP_ERROR_CHECK(telegram_bot_start());
ESP_ERROR_CHECK(feishu_bot_start());
cron_service_start();
heartbeat_start();
ESP_ERROR_CHECK(ws_server_start());
ESP_LOGI(TAG, "All services started!");
}
ESP_LOGI(TAG, "MimiClaw ready. Type 'help' for CLI commands.");

View File

@@ -117,6 +117,9 @@
#define MIMI_HEARTBEAT_FILE MIMI_SPIFFS_BASE "/HEARTBEAT.md"
#define MIMI_HEARTBEAT_INTERVAL_MS (30 * 60 * 1000)
/* GPIO */
#define MIMI_GPIO_CONFIG_SECTION 1 /* enable GPIO tools */
/* Skills */
#define MIMI_SKILLS_PREFIX MIMI_SPIFFS_BASE "/skills/"
@@ -149,3 +152,11 @@
#define MIMI_NVS_KEY_PROVIDER "provider"
#define MIMI_NVS_KEY_PROXY_HOST "host"
#define MIMI_NVS_KEY_PROXY_PORT "port"
#define MIMI_NVS_KEY_PROXY_TYPE "proxy_type"
/* WiFi Onboarding (Captive Portal) */
#define MIMI_ONBOARD_AP_PREFIX "MimiClaw-"
#define MIMI_ONBOARD_AP_PASS "" /* open network */
#define MIMI_ONBOARD_HTTP_PORT 80
#define MIMI_ONBOARD_DNS_STACK (4 * 1024)
#define MIMI_ONBOARD_MAX_SCAN 20

147
main/onboard/onboard_html.h Normal file
View File

@@ -0,0 +1,147 @@
#pragma once
static const char ONBOARD_HTML[] =
"<!DOCTYPE html><html><head>"
"<meta charset='utf-8'>"
"<meta name='viewport' content='width=device-width,initial-scale=1'>"
"<title>MimiClaw Setup</title>"
"<style>"
"*{box-sizing:border-box;margin:0;padding:0}"
"body{font-family:-apple-system,sans-serif;background:#f5f5f5;color:#333;padding:16px;max-width:480px;margin:0 auto}"
"h1{text-align:center;margin:16px 0;font-size:1.4em;color:#1a73e8}"
".card{background:#fff;border-radius:12px;margin:12px 0;box-shadow:0 1px 3px rgba(0,0,0,.12);overflow:hidden}"
".card-hdr{display:flex;justify-content:space-between;align-items:center;padding:14px 16px;cursor:pointer;user-select:none;font-weight:600;font-size:.95em}"
".card-hdr::after{content:'\\25BC';font-size:.7em;transition:transform .2s}"
".card.collapsed .card-hdr::after{transform:rotate(-90deg)}"
".card.collapsed .card-body{display:none}"
".card-body{padding:0 16px 16px}"
"label{display:block;margin:10px 0 4px;font-size:.85em;color:#555}"
"input,select{width:100%;padding:10px 12px;border:1px solid #ddd;border-radius:8px;font-size:.95em;outline:none}"
"input:focus,select:focus{border-color:#1a73e8}"
".btn{display:block;width:100%;padding:12px;border:none;border-radius:8px;font-size:1em;font-weight:600;cursor:pointer;margin:8px 0}"
".btn-scan{background:#e8f0fe;color:#1a73e8}"
".btn-save{background:#1a73e8;color:#fff;margin-top:20px;font-size:1.1em}"
".btn:active{opacity:.8}"
".ap-list{max-height:200px;overflow-y:auto;border:1px solid #ddd;border-radius:8px;margin:8px 0}"
".ap-item{padding:10px 12px;border-bottom:1px solid #eee;cursor:pointer;display:flex;justify-content:space-between}"
".ap-item:last-child{border-bottom:none}"
".ap-item:active{background:#e8f0fe}"
".ap-rssi{color:#888;font-size:.85em}"
".ap-lock::before{content:'\\1F512';font-size:.75em;margin-right:4px}"
".status{text-align:center;padding:20px;color:#1a73e8;font-size:1.1em;display:none}"
"</style></head><body>"
"<h1>MimiClaw Setup</h1>"
"<p style='text-align:center;color:#666;font-size:.9em;margin-bottom:12px'>"
"This local portal remains available at 192.168.4.1 for later updates."
"</p>"
/* WiFi section (expanded by default) */
"<div class='card' id='sec-wifi'>"
"<div class='card-hdr' onclick='toggle(this)'>WiFi Configuration</div>"
"<div class='card-body'>"
"<button class='btn btn-scan' onclick='scan()'>Scan WiFi Networks</button>"
"<div class='ap-list' id='ap-list' style='display:none'></div>"
"<label>SSID</label>"
"<input id='ssid' placeholder='WiFi network name'>"
"<label>Password</label>"
"<input id='password' type='password' placeholder='WiFi password'>"
"</div></div>"
/* LLM section */
"<div class='card collapsed' id='sec-llm'>"
"<div class='card-hdr' onclick='toggle(this)'>LLM Configuration</div>"
"<div class='card-body'>"
"<label>API Key</label>"
"<input id='api_key' type='password' placeholder='sk-...'>"
"<label>Model</label>"
"<input id='model' placeholder='claude-opus-4-5' value='claude-opus-4-5'>"
"<label>Provider</label>"
"<select id='provider'>"
"<option value='anthropic'>Anthropic</option>"
"<option value='openai'>OpenAI</option>"
"</select>"
"</div></div>"
/* Telegram section */
"<div class='card collapsed' id='sec-tg'>"
"<div class='card-hdr' onclick='toggle(this)'>Telegram Bot</div>"
"<div class='card-body'>"
"<label>Bot Token</label>"
"<input id='tg_token' placeholder='123456:ABC-DEF...'>"
"</div></div>"
/* Feishu section */
"<div class='card collapsed' id='sec-feishu'>"
"<div class='card-hdr' onclick='toggle(this)'>Feishu</div>"
"<div class='card-body'>"
"<label>App ID</label>"
"<input id='feishu_app_id' placeholder='cli_xxxx'>"
"<label>App Secret</label>"
"<input id='feishu_app_secret' type='password' placeholder='App Secret'>"
"</div></div>"
/* Proxy section */
"<div class='card collapsed' id='sec-proxy'>"
"<div class='card-hdr' onclick='toggle(this)'>Proxy</div>"
"<div class='card-body'>"
"<label>Host</label>"
"<input id='proxy_host' placeholder='192.168.1.100'>"
"<label>Port</label>"
"<input id='proxy_port' type='number' placeholder='7890'>"
"<label>Type</label>"
"<select id='proxy_type'>"
"<option value=''>None</option>"
"<option value='http'>HTTP</option>"
"<option value='socks5'>SOCKS5</option>"
"</select>"
"</div></div>"
/* Search section */
"<div class='card collapsed' id='sec-search'>"
"<div class='card-hdr' onclick='toggle(this)'>Search</div>"
"<div class='card-body'>"
"<label>Brave Search API Key</label>"
"<input id='search_key' type='password' placeholder='BSA...'>"
"<label>Tavily API Key</label>"
"<input id='tavily_key' type='password' placeholder='tvly-...'>"
"</div></div>"
"<button class='btn btn-save' onclick='save()'>Save &amp; Restart</button>"
"<div class='status' id='status'>Saving... Device will restart.</div>"
"<script>"
"function toggle(el){"
"el.parentElement.classList.toggle('collapsed')}"
"function loadConfig(){"
"fetch('/config').then(r=>r.json()).then(cfg=>{"
"Object.keys(cfg).forEach(k=>{"
"var el=document.getElementById(k);"
"if(el && cfg[k] !== undefined && cfg[k] !== null){el.value=cfg[k]}"
"})"
"}).catch(()=>{})}"
"function scan(){"
"var btn=event.target;btn.textContent='Scanning...';btn.disabled=true;"
"fetch('/scan').then(r=>r.json()).then(list=>{"
"var el=document.getElementById('ap-list');el.style.display='block';el.innerHTML='';"
"list.forEach(ap=>{"
"var d=document.createElement('div');d.className='ap-item';"
"d.innerHTML='<span>'+(ap.auth?'<span class=ap-lock></span>':'')+ap.ssid+'</span><span class=ap-rssi>'+ap.rssi+' dBm</span>';"
"d.onclick=function(){document.getElementById('ssid').value=ap.ssid};"
"el.appendChild(d)});"
"btn.textContent='Scan WiFi Networks';btn.disabled=false;"
"}).catch(()=>{btn.textContent='Scan WiFi Networks';btn.disabled=false})}"
"function save(){"
"var fields=['ssid','password','api_key','model','provider','tg_token',"
"'feishu_app_id','feishu_app_secret','proxy_host','proxy_port','proxy_type','search_key','tavily_key'];"
"var data={};"
"fields.forEach(f=>{data[f]=document.getElementById(f).value.trim()});"
"document.getElementById('status').style.display='block';"
"fetch('/save',{method:'POST',headers:{'Content-Type':'application/json'},body:JSON.stringify(data)})"
".then(()=>{document.getElementById('status').textContent='Saved! Restarting...';})"
".catch(()=>{document.getElementById('status').textContent='Error. Please try again.';})}"
"loadConfig();"
"</script>"
"</body></html>";

525
main/onboard/wifi_onboard.c Normal file
View File

@@ -0,0 +1,525 @@
#include "wifi_onboard.h"
#include "onboard_html.h"
#include "mimi_config.h"
#include "wifi/wifi_manager.h"
#include <stdint.h>
#include <stdlib.h>
#include <string.h>
#include "esp_log.h"
#include "esp_wifi.h"
#include "esp_netif.h"
#include "esp_mac.h"
#include "esp_http_server.h"
#include "esp_system.h"
#include "nvs.h"
#include "cJSON.h"
#include "freertos/FreeRTOS.h"
#include "freertos/task.h"
#include "lwip/sockets.h"
#include "lwip/netdb.h"
static const char *TAG = "onboard";
static httpd_handle_t s_server = NULL;
static bool s_captive_mode = false;
static void json_add_effective_config(cJSON *root, const char *json_key,
const char *ns, const char *nvs_key,
const char *build_val)
{
char value[256] = {0};
bool found = false;
nvs_handle_t nvs;
if (nvs_open(ns, NVS_READONLY, &nvs) == ESP_OK) {
size_t len = sizeof(value);
if (nvs_get_str(nvs, nvs_key, value, &len) == ESP_OK) {
found = true;
}
nvs_close(nvs);
}
if (!found && build_val) {
strlcpy(value, build_val, sizeof(value));
}
cJSON_AddStringToObject(root, json_key, value);
}
static void json_add_effective_config_u16(cJSON *root, const char *json_key,
const char *ns, const char *nvs_key,
const char *build_val)
{
char value[16] = {0};
bool found = false;
nvs_handle_t nvs;
if (nvs_open(ns, NVS_READONLY, &nvs) == ESP_OK) {
uint16_t port = 0;
if (nvs_get_u16(nvs, nvs_key, &port) == ESP_OK && port > 0) {
snprintf(value, sizeof(value), "%u", (unsigned)port);
found = true;
}
nvs_close(nvs);
}
if (!found && build_val) {
strlcpy(value, build_val, sizeof(value));
}
cJSON_AddStringToObject(root, json_key, value);
}
/* ── DNS hijack ─────────────────────────────────────────────────── */
/* Minimal DNS response: always answer 192.168.4.1 */
static void dns_hijack_task(void *arg)
{
int sock = socket(AF_INET, SOCK_DGRAM, IPPROTO_UDP);
if (sock < 0) {
ESP_LOGE(TAG, "DNS socket error");
vTaskDelete(NULL);
return;
}
struct sockaddr_in addr = {
.sin_family = AF_INET,
.sin_port = htons(53),
.sin_addr.s_addr = htonl(INADDR_ANY),
};
int opt = 1;
setsockopt(sock, SOL_SOCKET, SO_REUSEADDR, &opt, sizeof(opt));
if (bind(sock, (struct sockaddr *)&addr, sizeof(addr)) < 0) {
ESP_LOGE(TAG, "DNS bind failed");
close(sock);
vTaskDelete(NULL);
return;
}
ESP_LOGI(TAG, "DNS hijack listening on :53");
uint8_t buf[512];
struct sockaddr_in client;
socklen_t client_len;
while (1) {
client_len = sizeof(client);
int len = recvfrom(sock, buf, sizeof(buf), 0,
(struct sockaddr *)&client, &client_len);
if (len < 12) continue; /* too short for DNS header */
/* Build response: copy query, set response flags, append answer */
uint8_t resp[512];
if (len + 16 > (int)sizeof(resp)) continue;
memcpy(resp, buf, len);
/* Set QR=1 (response), AA=1 (authoritative), RA=1 */
resp[2] = 0x85; /* QR=1, Opcode=0, AA=1, TC=0, RD=1 */
resp[3] = 0x80; /* RA=1, Z=0, RCODE=0 (no error) */
/* Answer count = 1 */
resp[6] = 0x00;
resp[7] = 0x01;
/* Append answer: pointer to name + A record with 192.168.4.1 */
int off = len;
resp[off++] = 0xC0; /* pointer */
resp[off++] = 0x0C; /* offset to question name */
resp[off++] = 0x00; resp[off++] = 0x01; /* type A */
resp[off++] = 0x00; resp[off++] = 0x01; /* class IN */
resp[off++] = 0x00; resp[off++] = 0x00;
resp[off++] = 0x00; resp[off++] = 0x3C; /* TTL = 60 */
resp[off++] = 0x00; resp[off++] = 0x04; /* data length = 4 */
resp[off++] = 192; resp[off++] = 168;
resp[off++] = 4; resp[off++] = 1;
sendto(sock, resp, off, 0,
(struct sockaddr *)&client, client_len);
}
}
/* ── HTTP handlers ──────────────────────────────────────────────── */
static esp_err_t http_get_root(httpd_req_t *req)
{
httpd_resp_set_type(req, "text/html");
httpd_resp_set_hdr(req, "Cache-Control", "no-cache");
return httpd_resp_send(req, ONBOARD_HTML, sizeof(ONBOARD_HTML) - 1);
}
/* Captive portal detection endpoints → redirect to root */
static esp_err_t http_captive_redirect(httpd_req_t *req)
{
httpd_resp_set_status(req, "302 Found");
httpd_resp_set_hdr(req, "Location", "http://192.168.4.1/");
return httpd_resp_send(req, NULL, 0);
}
static esp_err_t http_get_scan(httpd_req_t *req)
{
wifi_scan_config_t scan_cfg = {
.ssid = NULL,
.bssid = NULL,
.channel = 0,
.show_hidden = true,
};
esp_err_t err = esp_wifi_scan_start(&scan_cfg, true);
if (err != ESP_OK) {
httpd_resp_send_err(req, HTTPD_500_INTERNAL_SERVER_ERROR, "Scan failed");
return ESP_FAIL;
}
uint16_t ap_count = 0;
esp_wifi_scan_get_ap_num(&ap_count);
if (ap_count > MIMI_ONBOARD_MAX_SCAN) ap_count = MIMI_ONBOARD_MAX_SCAN;
wifi_ap_record_t *ap_list = calloc(ap_count, sizeof(wifi_ap_record_t));
if (!ap_list) {
httpd_resp_send_err(req, HTTPD_500_INTERNAL_SERVER_ERROR, "OOM");
return ESP_FAIL;
}
uint16_t ap_max = ap_count;
esp_wifi_scan_get_ap_records(&ap_max, ap_list);
cJSON *arr = cJSON_CreateArray();
for (uint16_t i = 0; i < ap_max; i++) {
if (ap_list[i].ssid[0] == '\0') continue; /* skip hidden */
cJSON *obj = cJSON_CreateObject();
cJSON_AddStringToObject(obj, "ssid", (const char *)ap_list[i].ssid);
cJSON_AddNumberToObject(obj, "rssi", ap_list[i].rssi);
cJSON_AddNumberToObject(obj, "ch", ap_list[i].primary);
cJSON_AddBoolToObject(obj, "auth", ap_list[i].authmode != WIFI_AUTH_OPEN);
cJSON_AddItemToArray(arr, obj);
}
free(ap_list);
char *json = cJSON_PrintUnformatted(arr);
cJSON_Delete(arr);
httpd_resp_set_type(req, "application/json");
esp_err_t ret = httpd_resp_send(req, json, strlen(json));
free(json);
return ret;
}
static esp_err_t http_get_config(httpd_req_t *req)
{
cJSON *root = cJSON_CreateObject();
if (!root) {
httpd_resp_send_err(req, HTTPD_500_INTERNAL_SERVER_ERROR, "OOM");
return ESP_FAIL;
}
json_add_effective_config(root, "ssid", MIMI_NVS_WIFI, MIMI_NVS_KEY_SSID, MIMI_SECRET_WIFI_SSID);
json_add_effective_config(root, "password", MIMI_NVS_WIFI, MIMI_NVS_KEY_PASS, MIMI_SECRET_WIFI_PASS);
json_add_effective_config(root, "api_key", MIMI_NVS_LLM, MIMI_NVS_KEY_API_KEY, MIMI_SECRET_API_KEY);
json_add_effective_config(root, "model", MIMI_NVS_LLM, MIMI_NVS_KEY_MODEL, MIMI_SECRET_MODEL);
json_add_effective_config(root, "provider", MIMI_NVS_LLM, MIMI_NVS_KEY_PROVIDER, MIMI_SECRET_MODEL_PROVIDER);
json_add_effective_config(root, "tg_token", MIMI_NVS_TG, MIMI_NVS_KEY_TG_TOKEN, MIMI_SECRET_TG_TOKEN);
json_add_effective_config(root, "feishu_app_id", MIMI_NVS_FEISHU, MIMI_NVS_KEY_FEISHU_APP_ID, MIMI_SECRET_FEISHU_APP_ID);
json_add_effective_config(root, "feishu_app_secret", MIMI_NVS_FEISHU, MIMI_NVS_KEY_FEISHU_APP_SECRET, MIMI_SECRET_FEISHU_APP_SECRET);
json_add_effective_config(root, "proxy_host", MIMI_NVS_PROXY, MIMI_NVS_KEY_PROXY_HOST, MIMI_SECRET_PROXY_HOST);
json_add_effective_config_u16(root, "proxy_port", MIMI_NVS_PROXY, MIMI_NVS_KEY_PROXY_PORT, MIMI_SECRET_PROXY_PORT);
json_add_effective_config(root, "proxy_type", MIMI_NVS_PROXY, MIMI_NVS_KEY_PROXY_TYPE, MIMI_SECRET_PROXY_TYPE);
json_add_effective_config(root, "search_key", MIMI_NVS_SEARCH, MIMI_NVS_KEY_API_KEY, MIMI_SECRET_SEARCH_KEY);
json_add_effective_config(root, "tavily_key", MIMI_NVS_SEARCH, MIMI_NVS_KEY_TAVILY_KEY, MIMI_SECRET_TAVILY_KEY);
char *json = cJSON_PrintUnformatted(root);
cJSON_Delete(root);
if (!json) {
httpd_resp_send_err(req, HTTPD_500_INTERNAL_SERVER_ERROR, "OOM");
return ESP_FAIL;
}
httpd_resp_set_type(req, "application/json");
httpd_resp_set_hdr(req, "Cache-Control", "no-cache");
esp_err_t ret = httpd_resp_send(req, json, strlen(json));
free(json);
return ret;
}
/*
* Sync one JSON string field into NVS.
* - missing field: leave current NVS value unchanged
* - empty string: erase current NVS value
* - non-empty string: save/update current NVS value
*/
static void nvs_sync_field(cJSON *root, const char *json_key,
const char *ns, const char *nvs_key)
{
cJSON *item = cJSON_GetObjectItem(root, json_key);
if (!item || !cJSON_IsString(item)) return;
nvs_handle_t nvs;
if (nvs_open(ns, NVS_READWRITE, &nvs) == ESP_OK) {
if (item->valuestring[0] == '\0') {
esp_err_t err = nvs_erase_key(nvs, nvs_key);
if (err == ESP_OK) {
ESP_LOGI(TAG, "Cleared %s/%s", ns, nvs_key);
} else if (err != ESP_ERR_NVS_NOT_FOUND) {
ESP_LOGW(TAG, "Failed clearing %s/%s: %s", ns, nvs_key, esp_err_to_name(err));
}
} else {
nvs_set_str(nvs, nvs_key, item->valuestring);
ESP_LOGI(TAG, "Saved %s/%s", ns, nvs_key);
}
nvs_commit(nvs);
nvs_close(nvs);
}
}
static void nvs_sync_u16_field(cJSON *root, const char *json_key,
const char *ns, const char *nvs_key)
{
cJSON *item = cJSON_GetObjectItem(root, json_key);
if (!item || !cJSON_IsString(item)) return;
nvs_handle_t nvs;
if (nvs_open(ns, NVS_READWRITE, &nvs) == ESP_OK) {
if (item->valuestring[0] == '\0') {
esp_err_t err = nvs_erase_key(nvs, nvs_key);
if (err == ESP_OK) {
ESP_LOGI(TAG, "Cleared %s/%s", ns, nvs_key);
} else if (err != ESP_ERR_NVS_NOT_FOUND) {
ESP_LOGW(TAG, "Failed clearing %s/%s: %s", ns, nvs_key, esp_err_to_name(err));
}
} else {
char *end = NULL;
unsigned long value = strtoul(item->valuestring, &end, 10);
if (end == item->valuestring || *end != '\0' || value > UINT16_MAX) {
ESP_LOGW(TAG, "Ignoring invalid %s value: %s", json_key, item->valuestring);
} else {
ESP_ERROR_CHECK(nvs_set_u16(nvs, nvs_key, (uint16_t)value));
ESP_LOGI(TAG, "Saved %s/%s", ns, nvs_key);
}
}
nvs_commit(nvs);
nvs_close(nvs);
}
}
static esp_err_t http_post_save(httpd_req_t *req)
{
int total_len = req->content_len;
if (total_len <= 0 || total_len > 2048) {
httpd_resp_send_err(req, HTTPD_400_BAD_REQUEST, "Bad length");
return ESP_FAIL;
}
char *buf = calloc(1, total_len + 1);
if (!buf) {
httpd_resp_send_err(req, HTTPD_500_INTERNAL_SERVER_ERROR, "OOM");
return ESP_FAIL;
}
int received = 0;
while (received < total_len) {
int ret = httpd_req_recv(req, buf + received, total_len - received);
if (ret <= 0) {
free(buf);
httpd_resp_send_err(req, HTTPD_500_INTERNAL_SERVER_ERROR, "Recv error");
return ESP_FAIL;
}
received += ret;
}
cJSON *root = cJSON_Parse(buf);
free(buf);
if (!root) {
httpd_resp_send_err(req, HTTPD_400_BAD_REQUEST, "Invalid JSON");
return ESP_FAIL;
}
/* WiFi (required) */
nvs_sync_field(root, "ssid", MIMI_NVS_WIFI, MIMI_NVS_KEY_SSID);
nvs_sync_field(root, "password", MIMI_NVS_WIFI, MIMI_NVS_KEY_PASS);
/* LLM */
nvs_sync_field(root, "api_key", MIMI_NVS_LLM, MIMI_NVS_KEY_API_KEY);
nvs_sync_field(root, "model", MIMI_NVS_LLM, MIMI_NVS_KEY_MODEL);
nvs_sync_field(root, "provider", MIMI_NVS_LLM, MIMI_NVS_KEY_PROVIDER);
/* Telegram */
nvs_sync_field(root, "tg_token", MIMI_NVS_TG, MIMI_NVS_KEY_TG_TOKEN);
/* Feishu */
nvs_sync_field(root, "feishu_app_id", MIMI_NVS_FEISHU, MIMI_NVS_KEY_FEISHU_APP_ID);
nvs_sync_field(root, "feishu_app_secret", MIMI_NVS_FEISHU, MIMI_NVS_KEY_FEISHU_APP_SECRET);
/* Proxy */
nvs_sync_field(root, "proxy_host", MIMI_NVS_PROXY, MIMI_NVS_KEY_PROXY_HOST);
nvs_sync_u16_field(root, "proxy_port", MIMI_NVS_PROXY, MIMI_NVS_KEY_PROXY_PORT);
nvs_sync_field(root, "proxy_type", MIMI_NVS_PROXY, MIMI_NVS_KEY_PROXY_TYPE);
/* Search */
nvs_sync_field(root, "search_key", MIMI_NVS_SEARCH, MIMI_NVS_KEY_API_KEY);
nvs_sync_field(root, "tavily_key", MIMI_NVS_SEARCH, MIMI_NVS_KEY_TAVILY_KEY);
cJSON_Delete(root);
httpd_resp_set_type(req, "application/json");
httpd_resp_send(req, "{\"ok\":true}", 11);
ESP_LOGI(TAG, "Configuration saved, restarting in 2s...");
vTaskDelay(pdMS_TO_TICKS(2000));
esp_restart();
return ESP_OK; /* unreachable */
}
/* ── Soft AP + HTTP server startup ──────────────────────────────── */
static esp_err_t start_softap(bool keep_sta)
{
/* Get last 2 bytes of MAC for unique SSID suffix */
uint8_t mac[6];
esp_read_mac(mac, ESP_MAC_WIFI_SOFTAP);
char ssid[32];
snprintf(ssid, sizeof(ssid), "%s%02X%02X", MIMI_ONBOARD_AP_PREFIX, mac[4], mac[5]);
/* Create AP netif if not already present */
static esp_netif_t *ap_netif = NULL;
if (!ap_netif) {
ap_netif = esp_netif_create_default_wifi_ap();
}
/* APSTA lets the local config AP coexist with WiFi scanning/STA usage. */
(void)keep_sta;
ESP_ERROR_CHECK(esp_wifi_set_mode(WIFI_MODE_APSTA));
wifi_config_t ap_cfg = {
.ap = {
.max_connection = 4,
.authmode = WIFI_AUTH_OPEN,
.channel = 1,
},
};
strncpy((char *)ap_cfg.ap.ssid, ssid, sizeof(ap_cfg.ap.ssid) - 1);
ap_cfg.ap.ssid_len = strlen(ssid);
ESP_ERROR_CHECK(esp_wifi_set_config(WIFI_IF_AP, &ap_cfg));
esp_err_t err = esp_wifi_start();
if (err != ESP_OK && !(keep_sta && err == ESP_ERR_WIFI_CONN)) {
return err;
}
ESP_LOGI(TAG, "Soft AP started: %s (open)", ssid);
return ESP_OK;
}
static httpd_handle_t start_http_server(bool captive)
{
if (s_server) {
if (captive && !s_captive_mode) {
ESP_LOGW(TAG, "HTTP server already running without captive redirects");
}
return s_server;
}
httpd_config_t config = HTTPD_DEFAULT_CONFIG();
config.server_port = MIMI_ONBOARD_HTTP_PORT;
config.max_uri_handlers = captive ? 16 : 8;
config.stack_size = 8192;
config.lru_purge_enable = true;
if (httpd_start(&s_server, &config) != ESP_OK) {
ESP_LOGE(TAG, "Failed to start HTTP server");
return NULL;
}
/* Main page */
httpd_uri_t uri_root = {
.uri = "/", .method = HTTP_GET, .handler = http_get_root,
};
httpd_register_uri_handler(s_server, &uri_root);
httpd_uri_t uri_config = {
.uri = "/config", .method = HTTP_GET, .handler = http_get_config,
};
httpd_register_uri_handler(s_server, &uri_config);
/* WiFi scan */
httpd_uri_t uri_scan = {
.uri = "/scan", .method = HTTP_GET, .handler = http_get_scan,
};
httpd_register_uri_handler(s_server, &uri_scan);
/* Save config */
httpd_uri_t uri_save = {
.uri = "/save", .method = HTTP_POST, .handler = http_post_save,
};
httpd_register_uri_handler(s_server, &uri_save);
if (captive) {
/* Captive portal detection endpoints */
const char *captive_uris[] = {
"/generate_204", /* Android */
"/gen_204", /* Android alt */
"/hotspot-detect.html", /* iOS/macOS */
"/library/test/success.html", /* iOS alt */
"/connecttest.txt", /* Windows */
"/redirect", /* Windows alt */
};
for (int i = 0; i < sizeof(captive_uris) / sizeof(captive_uris[0]); i++) {
httpd_uri_t uri_captive = {
.uri = captive_uris[i],
.method = HTTP_GET,
.handler = http_captive_redirect,
};
httpd_register_uri_handler(s_server, &uri_captive);
}
}
s_captive_mode = captive;
ESP_LOGI(TAG, "HTTP server started on port %d", MIMI_ONBOARD_HTTP_PORT);
return s_server;
}
/* ── Public API ─────────────────────────────────────────────────── */
esp_err_t wifi_onboard_start(wifi_onboard_mode_t mode)
{
ESP_LOGI(TAG, "========================================");
ESP_LOGI(TAG, " Starting WiFi Configuration Portal");
ESP_LOGI(TAG, "========================================");
bool captive = (mode == WIFI_ONBOARD_MODE_CAPTIVE);
if (captive) {
/* Stop STA retries before starting captive portal. */
wifi_manager_set_reconnect_enabled(false);
wifi_manager_stop();
}
/* Start soft AP */
esp_err_t err = start_softap(!captive);
if (err != ESP_OK) return err;
if (captive) {
/* Start DNS hijack only for true captive portal mode. */
xTaskCreate(dns_hijack_task, "dns_hijack",
MIMI_ONBOARD_DNS_STACK, NULL, 5, NULL);
}
/* Start HTTP server */
httpd_handle_t server = start_http_server(captive);
if (!server) return ESP_FAIL;
ESP_LOGI(TAG, "Connect to MimiClaw-XXXX WiFi, then open http://192.168.4.1");
if (!captive) {
ESP_LOGI(TAG, "Local admin portal stays available while STA is connected");
return ESP_OK;
}
/* Block forever — onboarding ends with esp_restart() in /save handler */
while (1) {
vTaskDelay(pdMS_TO_TICKS(1000));
}
return ESP_OK; /* unreachable */
}

View File

@@ -0,0 +1,15 @@
#pragma once
#include "esp_err.h"
typedef enum {
WIFI_ONBOARD_MODE_CAPTIVE = 0,
WIFI_ONBOARD_MODE_ADMIN,
} wifi_onboard_mode_t;
/**
* Start WiFi onboarding/configuration portal.
* CAPTIVE mode opens DNS hijack + config page and blocks forever.
* ADMIN mode keeps a local config hotspot alive without captive redirects.
*/
esp_err_t wifi_onboard_start(wifi_onboard_mode_t mode);

123
main/tools/gpio_policy.c Normal file
View File

@@ -0,0 +1,123 @@
#include "tools/gpio_policy.h"
#include "driver/gpio.h"
#include <stdlib.h>
#include <stdio.h>
#include <string.h>
#ifndef GPIO_IS_VALID_GPIO
#define GPIO_IS_VALID_GPIO(pin) ((pin) >= 0)
#endif
static bool pin_in_allowlist(int pin, const char *csv)
{
const char *cursor;
if (!csv || csv[0] == '\0') {
return false;
}
cursor = csv;
while (*cursor != '\0') {
char *endptr = NULL;
long value;
while (*cursor == ' ' || *cursor == '\t' || *cursor == ',') {
cursor++;
}
if (*cursor == '\0') {
break;
}
value = strtol(cursor, &endptr, 10);
if (endptr == cursor) {
while (*cursor != '\0' && *cursor != ',') {
cursor++;
}
continue;
}
if ((int)value == pin) {
return true;
}
cursor = endptr;
}
return false;
}
static bool pin_is_allowed_impl(int pin,
const char *allowlist_csv,
int min_pin,
int max_pin,
bool block_esp32_flash_pins,
bool block_esp32s3_usb_pins)
{
bool in_policy;
if (pin < 0) {
return false;
}
/* Block ESP32 flash/PSRAM pins (GPIO 6-11) */
if (block_esp32_flash_pins && pin >= 6 && pin <= 11) {
return false;
}
/* USB Serial/JTAG uses GPIO19/20 on ESP32-S3 */
if (block_esp32s3_usb_pins && (pin == 19 || pin == 20)) {
return false;
}
if (allowlist_csv && allowlist_csv[0] != '\0') {
in_policy = pin_in_allowlist(pin, allowlist_csv);
} else {
in_policy = pin >= min_pin && pin <= max_pin;
}
if (!in_policy) {
return false;
}
return GPIO_IS_VALID_GPIO((gpio_num_t)pin);
}
bool gpio_policy_pin_is_allowed(int pin)
{
#if defined(CONFIG_IDF_TARGET_ESP32)
return pin_is_allowed_impl(pin, MIMI_GPIO_ALLOWED_CSV,
MIMI_GPIO_MIN_PIN, MIMI_GPIO_MAX_PIN, true, false);
#elif defined(CONFIG_IDF_TARGET_ESP32S3)
return pin_is_allowed_impl(pin, MIMI_GPIO_ALLOWED_CSV,
MIMI_GPIO_MIN_PIN, MIMI_GPIO_MAX_PIN, false, true);
#else
return pin_is_allowed_impl(pin, MIMI_GPIO_ALLOWED_CSV,
MIMI_GPIO_MIN_PIN, MIMI_GPIO_MAX_PIN, false, false);
#endif
}
bool gpio_policy_pin_forbidden_hint(int pin, char *result, size_t result_len)
{
#if defined(CONFIG_IDF_TARGET_ESP32)
if (pin >= 6 && pin <= 11) {
snprintf(result, result_len,
"Error: pin %d is reserved for ESP32 flash/PSRAM (GPIO6-11); choose a different pin",
pin);
return true;
}
#elif defined(CONFIG_IDF_TARGET_ESP32S3)
if (pin == 19 || pin == 20) {
snprintf(result, result_len,
"Error: pin %d is reserved for ESP32-S3 USB Serial/JTAG (GPIO19/20); choose a different pin",
pin);
return true;
}
#else
(void)pin;
(void)result;
(void)result_len;
#endif
return false;
}

22
main/tools/gpio_policy.h Normal file
View File

@@ -0,0 +1,22 @@
#pragma once
#include <stdbool.h>
#include <stddef.h>
/* GPIO defaults for ESP32-S3-LCD-1.47B safe user-accessible pins */
#define MIMI_GPIO_MIN_PIN 1
#define MIMI_GPIO_MAX_PIN 21
#define MIMI_GPIO_ALLOWED_CSV "1,2,3,4,5,6,7,8,9,10,11,12,13,14,15,16,17,18,21,38,46"
/**
* Check if a pin is allowed for user GPIO operations.
* Validates against the allowlist or default range, and blocks
* pins reserved for flash/PSRAM on ESP32.
*/
bool gpio_policy_pin_is_allowed(int pin);
/**
* Write a human-readable hint if the pin is forbidden for a known reason.
* Returns true if a hint was written (and the caller should return the error).
*/
bool gpio_policy_pin_forbidden_hint(int pin, char *result, size_t result_len);

194
main/tools/tool_gpio.c Normal file
View File

@@ -0,0 +1,194 @@
#include "tools/tool_gpio.h"
#include "tools/gpio_policy.h"
#include "mimi_config.h"
#include "driver/gpio.h"
#include "esp_log.h"
#include "cJSON.h"
#include <string.h>
#include <stdio.h>
static const char *TAG = "tool_gpio";
esp_err_t tool_gpio_init(void)
{
ESP_LOGI(TAG, "GPIO tool initialized (pin range %d-%d)",
MIMI_GPIO_MIN_PIN, MIMI_GPIO_MAX_PIN);
return ESP_OK;
}
esp_err_t tool_gpio_write_execute(const char *input_json, char *output, size_t output_size)
{
cJSON *root = cJSON_Parse(input_json);
if (!root) {
snprintf(output, output_size, "Error: invalid JSON input");
return ESP_ERR_INVALID_ARG;
}
cJSON *pin_obj = cJSON_GetObjectItem(root, "pin");
cJSON *state_obj = cJSON_GetObjectItem(root, "state");
if (!cJSON_IsNumber(pin_obj)) {
snprintf(output, output_size, "Error: 'pin' required (integer)");
cJSON_Delete(root);
return ESP_ERR_INVALID_ARG;
}
if (!cJSON_IsNumber(state_obj)) {
snprintf(output, output_size, "Error: 'state' required (0 or 1)");
cJSON_Delete(root);
return ESP_ERR_INVALID_ARG;
}
int pin = (int)pin_obj->valuedouble;
int state = (int)state_obj->valuedouble;
if (!gpio_policy_pin_is_allowed(pin)) {
if (gpio_policy_pin_forbidden_hint(pin, output, output_size)) {
cJSON_Delete(root);
return ESP_ERR_INVALID_ARG;
}
if (MIMI_GPIO_ALLOWED_CSV[0] != '\0') {
snprintf(output, output_size, "Error: pin %d is not in allowed list", pin);
} else {
snprintf(output, output_size, "Error: pin must be %d-%d",
MIMI_GPIO_MIN_PIN, MIMI_GPIO_MAX_PIN);
}
cJSON_Delete(root);
return ESP_ERR_INVALID_ARG;
}
if (gpio_set_direction(pin, GPIO_MODE_INPUT_OUTPUT) != ESP_OK ||
gpio_set_level(pin, state ? 1 : 0) != ESP_OK) {
snprintf(output, output_size, "Error: failed to configure/write pin %d", pin);
cJSON_Delete(root);
return ESP_FAIL;
}
snprintf(output, output_size, "Pin %d set to %s", pin, state ? "HIGH" : "LOW");
ESP_LOGI(TAG, "gpio_write: pin %d -> %s", pin, state ? "HIGH" : "LOW");
cJSON_Delete(root);
return ESP_OK;
}
esp_err_t tool_gpio_read_execute(const char *input_json, char *output, size_t output_size)
{
cJSON *root = cJSON_Parse(input_json);
if (!root) {
snprintf(output, output_size, "Error: invalid JSON input");
return ESP_ERR_INVALID_ARG;
}
cJSON *pin_obj = cJSON_GetObjectItem(root, "pin");
if (!cJSON_IsNumber(pin_obj)) {
snprintf(output, output_size, "Error: 'pin' required (integer)");
cJSON_Delete(root);
return ESP_ERR_INVALID_ARG;
}
int pin = (int)pin_obj->valuedouble;
if (!gpio_policy_pin_is_allowed(pin)) {
if (gpio_policy_pin_forbidden_hint(pin, output, output_size)) {
cJSON_Delete(root);
return ESP_ERR_INVALID_ARG;
}
if (MIMI_GPIO_ALLOWED_CSV[0] != '\0') {
snprintf(output, output_size, "Error: pin %d is not in allowed list", pin);
} else {
snprintf(output, output_size, "Error: pin must be %d-%d",
MIMI_GPIO_MIN_PIN, MIMI_GPIO_MAX_PIN);
}
cJSON_Delete(root);
return ESP_ERR_INVALID_ARG;
}
/* Enable input path, then read level */
gpio_set_direction(pin, GPIO_MODE_INPUT);
int level = gpio_get_level(pin);
snprintf(output, output_size, "Pin %d = %s", pin, level ? "HIGH" : "LOW");
ESP_LOGI(TAG, "gpio_read: pin %d = %s", pin, level ? "HIGH" : "LOW");
cJSON_Delete(root);
return ESP_OK;
}
esp_err_t tool_gpio_read_all_execute(const char *input_json, char *output, size_t output_size)
{
(void)input_json;
char *cursor = output;
size_t remaining = output_size;
int written;
int count = 0;
written = snprintf(cursor, remaining, "GPIO states: ");
if (written < 0 || (size_t)written >= remaining) {
output[0] = '\0';
return ESP_FAIL;
}
cursor += (size_t)written;
remaining -= (size_t)written;
if (MIMI_GPIO_ALLOWED_CSV[0] != '\0') {
/* Iterate over explicit allowlist */
const char *csv_cursor = MIMI_GPIO_ALLOWED_CSV;
while (*csv_cursor != '\0') {
char *endptr = NULL;
long value;
while (*csv_cursor == ' ' || *csv_cursor == '\t' || *csv_cursor == ',') {
csv_cursor++;
}
if (*csv_cursor == '\0') break;
value = strtol(csv_cursor, &endptr, 10);
if (endptr == csv_cursor) {
while (*csv_cursor != '\0' && *csv_cursor != ',') csv_cursor++;
continue;
}
if (!gpio_policy_pin_is_allowed((int)value)) {
csv_cursor = endptr;
continue;
}
gpio_set_direction((int)value, GPIO_MODE_INPUT);
int level = gpio_get_level((int)value);
written = snprintf(cursor, remaining, "%s%d=%s",
count == 0 ? "" : ", ",
(int)value, level ? "HIGH" : "LOW");
if (written < 0 || (size_t)written >= remaining) break;
cursor += (size_t)written;
remaining -= (size_t)written;
count++;
csv_cursor = endptr;
}
} else {
/* Iterate over default range */
for (int pin = MIMI_GPIO_MIN_PIN; pin <= MIMI_GPIO_MAX_PIN; pin++) {
if (!gpio_policy_pin_is_allowed(pin)) continue;
gpio_set_direction(pin, GPIO_MODE_INPUT);
int level = gpio_get_level(pin);
written = snprintf(cursor, remaining, "%s%d=%s",
count == 0 ? "" : ", ",
pin, level ? "HIGH" : "LOW");
if (written < 0 || (size_t)written >= remaining) break;
cursor += (size_t)written;
remaining -= (size_t)written;
count++;
}
}
if (count == 0) {
snprintf(output, output_size, "Error: no allowed GPIO pins configured");
return ESP_FAIL;
}
ESP_LOGI(TAG, "gpio_read_all: %d pins read", count);
return ESP_OK;
}

27
main/tools/tool_gpio.h Normal file
View File

@@ -0,0 +1,27 @@
#pragma once
#include "esp_err.h"
#include <stddef.h>
/**
* Initialize GPIO tool — configure allowed pins and directions.
*/
esp_err_t tool_gpio_init(void);
/**
* Write a GPIO pin HIGH or LOW.
* Input JSON: {"pin": <int>, "state": <0|1>}
*/
esp_err_t tool_gpio_write_execute(const char *input_json, char *output, size_t output_size);
/**
* Read a single GPIO pin state.
* Input JSON: {"pin": <int>}
*/
esp_err_t tool_gpio_read_execute(const char *input_json, char *output, size_t output_size);
/**
* Read all allowed GPIO pin states at once.
* Input JSON: {} (no parameters)
*/
esp_err_t tool_gpio_read_all_execute(const char *input_json, char *output, size_t output_size);

View File

@@ -4,6 +4,7 @@
#include "tools/tool_get_time.h"
#include "tools/tool_files.h"
#include "tools/tool_cron.h"
#include "tools/tool_gpio.h"
#include <string.h>
#include "esp_log.h"
@@ -11,7 +12,7 @@
static const char *TAG = "tools";
#define MAX_TOOLS 12
#define MAX_TOOLS 16
static mimi_tool_t s_tools[MAX_TOOLS];
static int s_tool_count = 0;
@@ -176,6 +177,43 @@ esp_err_t tool_registry_init(void)
};
register_tool(&cr);
/* Register GPIO tools */
tool_gpio_init();
mimi_tool_t gw = {
.name = "gpio_write",
.description = "Set a GPIO pin HIGH or LOW. Controls LEDs, relays, and other digital outputs.",
.input_schema_json =
"{\"type\":\"object\","
"\"properties\":{\"pin\":{\"type\":\"integer\",\"description\":\"GPIO pin number\"},"
"\"state\":{\"type\":\"integer\",\"description\":\"1 for HIGH, 0 for LOW\"}},"
"\"required\":[\"pin\",\"state\"]}",
.execute = tool_gpio_write_execute,
};
register_tool(&gw);
mimi_tool_t gr = {
.name = "gpio_read",
.description = "Read a GPIO pin state. Returns HIGH or LOW. Use for checking switches, sensors, and digital inputs.",
.input_schema_json =
"{\"type\":\"object\","
"\"properties\":{\"pin\":{\"type\":\"integer\",\"description\":\"GPIO pin number\"}},"
"\"required\":[\"pin\"]}",
.execute = tool_gpio_read_execute,
};
register_tool(&gr);
mimi_tool_t ga = {
.name = "gpio_read_all",
.description = "Read all allowed GPIO pin states in a single call. Returns each pin's HIGH/LOW state.",
.input_schema_json =
"{\"type\":\"object\","
"\"properties\":{},"
"\"required\":[]}",
.execute = tool_gpio_read_all_execute,
};
register_tool(&ga);
build_tools_json();
ESP_LOGI(TAG, "Tool registry initialized");

View File

@@ -15,6 +15,7 @@ static EventGroupHandle_t s_wifi_event_group;
static int s_retry_count = 0;
static char s_ip_str[16] = "0.0.0.0";
static bool s_connected = false;
static bool s_reconnect_enabled = true;
static const char *wifi_reason_to_str(wifi_err_reason_t reason)
{
@@ -44,7 +45,7 @@ static void event_handler(void *arg, esp_event_base_t event_base,
if (disc) {
ESP_LOGW(TAG, "Disconnected (reason=%d:%s)", disc->reason, wifi_reason_to_str(disc->reason));
}
if (s_retry_count < MIMI_WIFI_MAX_RETRY) {
if (s_reconnect_enabled && s_retry_count < MIMI_WIFI_MAX_RETRY) {
/* Exponential backoff: 1s, 2s, 4s, 8s, ... capped at 30s */
uint32_t delay_ms = MIMI_WIFI_RETRY_BASE_MS << s_retry_count;
if (delay_ms > MIMI_WIFI_RETRY_MAX_MS) {
@@ -85,8 +86,6 @@ esp_err_t wifi_manager_init(void)
ESP_ERROR_CHECK(esp_event_handler_instance_register(
IP_EVENT, IP_EVENT_STA_GOT_IP, &event_handler, NULL, NULL));
ESP_ERROR_CHECK(esp_wifi_set_mode(WIFI_MODE_STA));
ESP_LOGI(TAG, "WiFi manager initialized");
return ESP_OK;
}
@@ -122,8 +121,10 @@ esp_err_t wifi_manager_start(void)
return ESP_ERR_NOT_FOUND;
}
s_reconnect_enabled = true;
ESP_LOGI(TAG, "Connecting to SSID: %s", wifi_cfg.sta.ssid);
ESP_ERROR_CHECK(esp_wifi_set_mode(WIFI_MODE_STA));
ESP_ERROR_CHECK(esp_wifi_set_config(WIFI_IF_STA, &wifi_cfg));
ESP_ERROR_CHECK(esp_wifi_start());
@@ -232,3 +233,41 @@ void wifi_manager_scan_and_print(void)
free(ap_list);
esp_wifi_connect();
}
bool wifi_manager_has_credentials(void)
{
/* Check NVS first */
nvs_handle_t nvs;
if (nvs_open(MIMI_NVS_WIFI, NVS_READONLY, &nvs) == ESP_OK) {
char ssid[33] = {0};
size_t len = sizeof(ssid);
esp_err_t err = nvs_get_str(nvs, MIMI_NVS_KEY_SSID, ssid, &len);
nvs_close(nvs);
if (err == ESP_OK && ssid[0] != '\0') return true;
}
/* Fall back to build-time secrets */
if (MIMI_SECRET_WIFI_SSID[0] != '\0') return true;
return false;
}
esp_err_t wifi_manager_stop(void)
{
s_reconnect_enabled = false;
esp_wifi_disconnect();
esp_wifi_stop();
s_connected = false;
s_retry_count = 0;
xEventGroupClearBits(s_wifi_event_group, WIFI_CONNECTED_BIT | WIFI_FAIL_BIT);
ESP_LOGI(TAG, "WiFi stopped");
return ESP_OK;
}
void wifi_manager_set_reconnect_enabled(bool enabled)
{
s_reconnect_enabled = enabled;
if (!enabled) {
s_retry_count = 0;
}
}

View File

@@ -49,3 +49,18 @@ EventGroupHandle_t wifi_manager_get_event_group(void);
* Scan and print nearby APs.
*/
void wifi_manager_scan_and_print(void);
/**
* Check if WiFi credentials exist (NVS or build-time secrets).
*/
bool wifi_manager_has_credentials(void);
/**
* Stop WiFi (for mode switching during onboarding).
*/
esp_err_t wifi_manager_stop(void);
/**
* Enable or disable STA auto-reconnect on disconnect events.
*/
void wifi_manager_set_reconnect_enabled(bool enabled);

View File

@@ -3,3 +3,6 @@
# Network hostname
CONFIG_LWIP_LOCAL_HOSTNAME="mimiclaw"
# SPIFFS: increase max filename length (default 32 is too short for session files)
CONFIG_SPIFFS_OBJ_NAME_LEN=64

View File

@@ -35,7 +35,7 @@ CONFIG_HTTPD_WS_SUPPORT=y
CONFIG_PARTITION_TABLE_CUSTOM=y
CONFIG_PARTITION_TABLE_CUSTOM_FILENAME="partitions.csv"
# Console: UART primary (non-blocking), USB Serial/JTAG secondary
# Prevents device hang when no USB host is connected (issue #60)
CONFIG_ESP_CONSOLE_UART_DEFAULT=y
CONFIG_ESP_CONSOLE_SECONDARY_USB_SERIAL_JTAG=y
# Console: USB Serial/JTAG primary (supports both input and output via USB)
# ESP-IDF v5.5+ has TX timeout protection (50ms) so device won't hang
# when no USB host is connected.
CONFIG_ESP_CONSOLE_USB_SERIAL_JTAG=y

View File

@@ -0,0 +1,37 @@
# GPIO Control
Control and monitor GPIO pins on the ESP32-S3 for digital I/O.
## When to use
When the user asks to:
- Turn on/off LEDs, relays, or other outputs
- Check switch states, button presses, or sensor readings
- Confirm digital I/O status (switch confirmation)
- Get an overview of all GPIO pin states
## How to use
1. To **read a switch/sensor**: use gpio_read with the pin number
- Returns HIGH (1) or LOW (0)
- HIGH typically means switch is ON / circuit closed
- LOW typically means switch is OFF / circuit open
2. To **set an output**: use gpio_write with pin and state (1=HIGH, 0=LOW)
3. To **scan all pins**: use gpio_read_all for a full status overview
4. For **switch confirmation**: read the pin, report state, optionally toggle and re-read to verify
## Pin safety
- Only pins within the allowed range can be accessed
- ESP32 flash pins (6-11) are always blocked
- If a pin is rejected, suggest an alternative within the allowed range
## Example
User: "Check if the switch on pin 4 is on"
→ gpio_read {"pin": 4}
→ "Pin 4 = HIGH"
→ "The switch on pin 4 is currently ON (HIGH)."
User: "Turn on the relay on pin 5"
→ gpio_write {"pin": 5, "state": 1}
→ "Pin 5 set to HIGH"
→ gpio_read {"pin": 5}
→ "Pin 5 = HIGH"
→ "Relay on pin 5 is now ON. Confirmed HIGH."

145
taolun.md Normal file
View File

@@ -0,0 +1,145 @@
# 讨论记录:增加国内大模型厂商接入
## 对话概述
**日期**2026-03-31
**目标**:为 MimiClaw 项目增加国内大模型厂商的接入,特别是硅基流动和火山方舟(豆包模型)
## 对话内容
### 用户需求
用户希望给当前项目增加国内大模型厂商的接入,包括:
1. 硅基流动的免费模型
2. 火山方舟的豆包模型
### 项目现状分析
#### 项目基本信息
- **项目名称**MimiClaw
- **运行平台**ESP32-S3纯C语言
- **交互方式**Telegram机器人
- **功能**AI助手支持工具调用本地记忆
#### 当前LLM集成状态
1. **支持的提供商**
- Anthropic (Claude)
- OpenAI (GPT)
2. **配置方式**
- 构建时配置:`main/mimi_secrets.h`
- 运行时配置串口CLI命令存储在NVS中
3. **关键代码文件**
- `main/llm/llm_proxy.c`LLM代理核心实现
- `main/llm/llm_proxy.h`LLM代理头文件
- `main/mimi_config.h`:全局配置定义
- `main/cli/serial_cli.c`:命令行界面
4. **提供商检测机制**
- `provider_is_openai()`函数检查是否为OpenAI提供商
- 根据提供商选择不同的API URL、Host和Path
### 国内大模型厂商API兼容性调研
#### 硅基流动 (SiliconFlow)
- **API兼容性**OpenAI兼容模式
- **Base URL**`https://api.siliconflow.cn/v1`
- **特点**
- 提供100+高性能大模型
- 新用户注册送免费额度
- 价格比OpenAI官方便宜80%+
- 支持OpenClaw等工具集成
#### 火山方舟 (字节跳动豆包模型)
- **API兼容性**兼容OpenAI SDK
- **Base URL**`https://ark.cn-beijing.volces.com/api/v3`
- **特点**
- 豆包大模型系列
- 新用户首次开通可享受50万token免费试用
- 支持函数调用、工具调用等高级功能
### 技术实现分析
#### 当前架构特点
1. **提供商抽象**
- 使用`s_provider`变量存储提供商名称
- 通过`provider_is_openai()`函数区分提供商
- 根据提供商选择不同的API配置
2. **API调用流程**
- 构建请求体
- 设置请求头(根据提供商不同)
- 发送HTTP请求
- 解析响应(根据提供商不同)
3. **工具调用支持**
- 支持Anthropic的tool_use格式
- 支持OpenAI的function calling格式
- 有格式转换函数`convert_tools_openai()`
#### 实现方案讨论
由于硅基流动和火山方舟都提供OpenAI兼容的API理论上可以复用现有的OpenAI集成代码只需要
1. 修改Base URL
2. 可能需要调整认证方式
3. 可能需要处理特定的模型名称
## 待解决问题
1. **认证方式差异**
- 硅基流动使用API Key
- 火山方舟:可能使用不同的认证方式
2. **模型名称规范**
- 需要了解具体的模型ID格式
- 例如:硅基流动的`deepseek-ai/DeepSeek-V3`,火山方舟的豆包模型名称
3. **功能支持差异**
- 工具调用格式是否完全兼容
- 上下文长度限制
- 特殊功能支持情况
## 下一步计划
基于讨论,制定了以下实施计划:
### 阶段一:准备与设计
1. 详细调研硅基流动和火山方舟的API文档
2. 确定具体的实现方案
3. 设计配置结构和命令行接口
### 阶段二:核心实现
1. 修改LLM代理以支持新的提供商
2. 添加配置管理功能
3. 更新命令行界面
### 阶段三:测试与优化
1. 功能测试
2. 性能优化
3. 文档更新
## 相关资源
### 项目文件
- `main/llm/llm_proxy.c`LLM代理实现
- `main/llm/llm_proxy.h`LLM代理头文件
- `main/mimi_config.h`:配置定义
- `main/cli/serial_cli.c`:命令行界面
### 外部文档
- 硅基流动OpenClaw集成文档
- 火山方舟兼容OpenAI SDK文档
- ESP32-S3开发文档
## 技术要点总结
1. **复用现有架构**可以充分利用现有的OpenAI集成代码
2. **提供商扩展**:需要扩展提供商检测和配置机制
3. **配置管理**需要支持新的API密钥和Base URL配置
4. **兼容性处理**可能需要处理API响应格式的细微差异
## 风险与挑战
1. **API兼容性风险**:虽然声称兼容,但可能存在细微差异
2. **内存限制**ESP32-S3内存有限需要确保新功能不会导致内存不足
3. **网络稳定性**国内网络环境可能影响API调用稳定性
4. **认证安全性**需要确保API密钥的安全存储和传输