Compare commits
30 Commits
6f434f8cea
...
v1.1.0
| Author | SHA1 | Date | |
|---|---|---|---|
| 7dc4122778 | |||
| eedc6757d8 | |||
| 49d3a131b7 | |||
| 46e46b0a13 | |||
|
|
bb10ea0149 | ||
|
|
2b68d56c69 | ||
|
|
9f17f1d243 | ||
|
|
07ca630b69 | ||
|
|
5ff0920399 | ||
|
|
85459fb210 | ||
|
|
974ec7f974 | ||
|
|
ee1002b160 | ||
|
|
6ee8aff00f | ||
|
|
73df856b06 | ||
|
|
c1f13fa38c | ||
|
|
37c0b0d6ee | ||
|
|
05c23b2f65 | ||
|
|
4d5d2e1e5a | ||
|
|
8eb048d681 | ||
|
|
c0ea5f22fb | ||
|
|
f0e26fd338 | ||
|
|
163f946e50 | ||
|
|
16eb01b49a | ||
|
|
6c283553f9 | ||
|
|
52f3bee043 | ||
|
|
cf9a1c11ae | ||
|
|
22ac11c9fd | ||
|
|
d30b3611f3 | ||
|
|
46dc186a80 | ||
|
|
e278264133 |
10
.github/workflows/release.yml
vendored
10
.github/workflows/release.yml
vendored
@@ -28,7 +28,7 @@ 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 \
|
||||
@@ -37,13 +37,15 @@ jobs:
|
||||
0x0 build/bootloader/bootloader.bin \
|
||||
0x8000 build/partition_table/partition-table.bin \
|
||||
0xf000 build/ota_data_initial.bin \
|
||||
0x20000 build/mimiclaw.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
|
||||
@@ -67,7 +69,8 @@ jobs:
|
||||
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
|
||||
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
143
AGENTS.md
Normal 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 命令
|
||||
|
||||
连接到 UART(COM)端口后,可用的配置命令:
|
||||
|
||||
```
|
||||
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` 中初始化新通道
|
||||
33
README.md
33
README.md
@@ -31,7 +31,7 @@ MimiClaw turns a tiny ESP32-S3 board into a personal AI assistant. Plug it into
|
||||
|
||||

|
||||
|
||||
You send a message on Telegram. The ESP32-S3 picks it up over WiFi, feeds it into an agent loop — the LLM thinks, calls tools, reads memory — and sends the reply back. Supports both **Anthropic (Claude)** and **OpenAI (GPT)** as providers, switchable at runtime. Everything runs on a single $5 chip with all your data stored locally on flash.
|
||||
You send a message on Telegram. The ESP32-S3 picks it up over WiFi, feeds it into an agent loop — the LLM thinks, calls tools, reads memory — and sends the reply back. Supports **Anthropic (Claude)**, **OpenAI (GPT)**, **SiliconFlow** (Chinese LLM providers), and **Volcengine** (ByteDance Doubao models) as providers, switchable at runtime. Everything runs on a single $5 chip with all your data stored locally on flash.
|
||||
|
||||
## Quick Start
|
||||
|
||||
@@ -40,7 +40,7 @@ You send a message on Telegram. The ESP32-S3 picks it up over WiFi, feeds it int
|
||||
- An **ESP32-S3 dev board** with 16 MB flash and 8 MB PSRAM (e.g. Xiaozhi AI board, ~$10)
|
||||
- A **USB Type-C cable**
|
||||
- A **Telegram bot token** — talk to [@BotFather](https://t.me/BotFather) on Telegram to create one
|
||||
- An **Anthropic API key** — from [console.anthropic.com](https://console.anthropic.com), or an **OpenAI API key** — from [platform.openai.com](https://platform.openai.com)
|
||||
- An **Anthropic API key** — from [console.anthropic.com](https://console.anthropic.com), or an **OpenAI API key** — from [platform.openai.com](https://platform.openai.com), or a **SiliconFlow API key** — from [siliconflow.cn](https://siliconflow.cn), or a **Volcengine API key** — from [volcengine.com](https://volcengine.com)
|
||||
|
||||
### Install
|
||||
|
||||
@@ -128,11 +128,19 @@ Edit `main/mimi_secrets.h`:
|
||||
#define MIMI_SECRET_WIFI_PASS "YourWiFiPassword"
|
||||
#define MIMI_SECRET_TG_TOKEN "123456:ABC-DEF1234ghIkl-zyx57W2v1u123ew11"
|
||||
#define MIMI_SECRET_API_KEY "sk-ant-api03-xxxxx"
|
||||
#define MIMI_SECRET_MODEL_PROVIDER "anthropic" // "anthropic" or "openai"
|
||||
#define MIMI_SECRET_MODEL_PROVIDER "anthropic" // "anthropic", "openai", "siliconflow", or "volcengine"
|
||||
#define MIMI_SECRET_SEARCH_KEY "" // optional: Brave Search API key
|
||||
#define MIMI_SECRET_TAVILY_KEY "" // optional: Tavily API key (preferred)
|
||||
#define MIMI_SECRET_PROXY_HOST "" // optional: e.g. "10.0.0.1"
|
||||
#define MIMI_SECRET_PROXY_PORT "" // optional: e.g. "7897"
|
||||
|
||||
/* Optional: SiliconFlow API (OpenAI-compatible) */
|
||||
#define MIMI_SECRET_SILICONFLOW_API_KEY ""
|
||||
#define MIMI_SECRET_SILICONFLOW_BASE_URL "https://api.siliconflow.cn/v1"
|
||||
|
||||
/* Optional: Volcengine API (OpenAI-compatible, ByteDance Doubao models) */
|
||||
#define MIMI_SECRET_VOLCENGINE_API_KEY ""
|
||||
#define MIMI_SECRET_VOLCENGINE_BASE_URL "https://ark.cn-beijing.volces.com/api/v3"
|
||||
```
|
||||
|
||||
Then build and flash:
|
||||
@@ -169,12 +177,16 @@ Connect via serial to configure or debug. **Config commands** let you change set
|
||||
mimi> wifi_set MySSID MyPassword # change WiFi network
|
||||
mimi> set_tg_token 123456:ABC... # change Telegram bot token
|
||||
mimi> set_api_key sk-ant-api03-... # change API key (Anthropic or OpenAI)
|
||||
mimi> set_model_provider openai # switch provider (anthropic|openai)
|
||||
mimi> set_model_provider openai # switch provider (anthropic|openai|siliconflow|volcengine)
|
||||
mimi> set_model gpt-4o # change LLM model
|
||||
mimi> set_proxy 127.0.0.1 7897 # set HTTP proxy
|
||||
mimi> clear_proxy # remove proxy
|
||||
mimi> set_search_key BSA... # set Brave Search API key
|
||||
mimi> set_tavily_key tvly-... # set Tavily API key (preferred)
|
||||
mimi> set_siliconflow_key sk-... # set SiliconFlow API key
|
||||
mimi> set_siliconflow_url https://api.siliconflow.cn/v1 # set SiliconFlow Base URL
|
||||
mimi> set_volcengine_key sk-... # set Volcengine API key
|
||||
mimi> set_volcengine_url https://ark.cn-beijing.volces.com/api/v3 # set Volcengine Base URL
|
||||
mimi> config_show # show all config (masked)
|
||||
mimi> config_reset # clear NVS, revert to build-time defaults
|
||||
```
|
||||
@@ -250,7 +262,7 @@ MimiClaw stores everything as plain text files you can read and edit:
|
||||
|
||||
## Tools
|
||||
|
||||
MimiClaw supports tool calling for both Anthropic and OpenAI — the LLM can call tools during a conversation and loop until the task is done (ReAct pattern).
|
||||
MimiClaw supports tool calling for all LLM providers — the LLM can call tools during a conversation and loop until the task is done (ReAct pattern). Supported providers include Anthropic (Claude), OpenAI (GPT), SiliconFlow, and Volcengine.
|
||||
|
||||
| Tool | Description |
|
||||
|------|-------------|
|
||||
@@ -280,7 +292,7 @@ This turns MimiClaw into a proactive assistant — write tasks to `HEARTBEAT.md`
|
||||
- **OTA updates** — flash new firmware over WiFi, no USB needed
|
||||
- **Dual-core** — network I/O and AI processing run on separate CPU cores
|
||||
- **HTTP proxy** — CONNECT tunnel support for restricted networks
|
||||
- **Multi-provider** — supports both Anthropic (Claude) and OpenAI (GPT), switchable at runtime
|
||||
- **Multi-provider** — supports Anthropic (Claude), OpenAI (GPT), SiliconFlow, and Volcengine, switchable at runtime
|
||||
- **Cron scheduler** — the AI can schedule its own recurring and one-shot tasks, persisted across reboots
|
||||
- **Heartbeat** — periodically checks a task file and prompts the AI to act autonomously
|
||||
- **Tool use** — ReAct agent loop with tool calling for both providers
|
||||
@@ -291,6 +303,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 +328,10 @@ Inspired by [OpenClaw](https://github.com/openclaw/openclaw) and [Nanobot](https
|
||||
|
||||
## Star History
|
||||
|
||||
[](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>
|
||||
|
||||
@@ -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 通道集成指南(飞书等)
|
||||
|
||||
## 贡献
|
||||
|
||||
@@ -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など)
|
||||
|
||||
## 貢献
|
||||
|
||||
56
changelog.md
Normal file
56
changelog.md
Normal file
@@ -0,0 +1,56 @@
|
||||
# 变更日志
|
||||
|
||||
## v1.1.0(计划中)
|
||||
|
||||
### 新增
|
||||
- 国内大模型厂商接入支持(硅基流动、火山方舟)— 计划中
|
||||
- **时区设置功能**
|
||||
- 默认时区改为 `CST-8`(中国标准时间 UTC+8)
|
||||
- 新增 `set_timezone` CLI 命令(支持 POSIX 格式和城市名)
|
||||
- 新增 `timezone_show` CLI 命令
|
||||
- 新增 `set_timezone` LLM 工具(可通过对话设置时区)
|
||||
- 时区通过 NVS 持久化存储(`system_config` namespace)
|
||||
- 支持城市名映射(Asia/Shanghai → CST-8 等 18 个预设城市)
|
||||
- `config_show` 中显示当前时区配置
|
||||
|
||||
### 修复
|
||||
- ESP-IDF v6.0 编译适配
|
||||
- 修复 flash 大小配置(2MB → 16MB)
|
||||
- 修复 WiFi 断开原因码未定义问题(添加 `#ifdef` 保护)
|
||||
- 修复 CMakeLists.txt 缺少 `ota/ota_manager.c`
|
||||
- 修复 16 处头文件缺失问题:
|
||||
- `cli/serial_cli.c` 添加 `llm/llm_provider.h`
|
||||
- `llm/llm_provider.c` 添加 `esp_http_client.h`
|
||||
- `bus/message_bus.c` 添加 `freertos/FreeRTOS.h`, `freertos/queue.h`
|
||||
- `wifi/wifi_manager.c` 添加 `esp_event.h`, `freertos/FreeRTOS.h`, `freertos/task.h`, `freertos/event_groups.h`
|
||||
- `ota/ota_manager.c` 添加 `esp_system.h`
|
||||
- `channels/telegram/telegram_bot.c` 添加 `freertos/FreeRTOS.h`, `freertos/task.h`
|
||||
- `tools/tool_registry.c` 添加 `<stdlib.h>`
|
||||
- `proxy/http_proxy.c` 添加 `<sys/time.h>`
|
||||
- `gateway/ws_server.c` 添加 `<stdint.h>`
|
||||
- 验证 ESP-IDF v6.0 API 兼容性(esp_spiffs_info、esp_websocket_client_send_bin、esp_tls、console REPL 等均存在)
|
||||
|
||||
### 文档
|
||||
- 新增 `docs/ESP-IDF-V6-MIGRATION.md` — ESP-IDF v6.0 迁移适配记录
|
||||
- 更新 `taolun.md` — 讨论记录整理
|
||||
|
||||
---
|
||||
|
||||
## v1.0.0
|
||||
|
||||
### 功能
|
||||
- Telegram 机器人长轮询
|
||||
- Agent Loop(ReAct 工具调用,最多 10 轮迭代)
|
||||
- Claude API(Anthropic Messages API)
|
||||
- OpenAI API 支持
|
||||
- 工具注册 + web_search(Brave Search API)
|
||||
- 上下文构建器(系统提示 + 引导文件 + 记忆 + 工具指导)
|
||||
- 记忆存储(MEMORY.md + 每日笔记)
|
||||
- 会话管理(JSONL per chat_id,环形缓冲区历史)
|
||||
- WebSocket 网关(端口 18789,JSON 协议)
|
||||
- 串口 CLI(esp_console,调试/维护命令)
|
||||
- HTTP CONNECT 代理支持
|
||||
- OTA 更新
|
||||
- WiFi 管理器(构建时凭证,指数退避)
|
||||
- SPIFFS 存储
|
||||
- 构建时配置(`mimi_secrets.h`)+ 运行时 NVS 覆盖
|
||||
@@ -57,8 +57,16 @@ Telegram App (User)
|
||||
│ Anthropic Messages API (HTTPS)
|
||||
│ + Brave Search API (HTTPS)
|
||||
▼
|
||||
┌───────────┐ ┌──────────────┐ ┌──────────────┐
|
||||
│ Claude API │ │ Brave Search │ │ Tavily Search│
|
||||
└───────────┘ └──────────────┘ └──────────────┘
|
||||
│
|
||||
┌───────────┐ ┌──────────────┐
|
||||
│ Claude API │ │ Brave Search │
|
||||
│ OpenAI API │ │ SiliconFlow │
|
||||
└───────────┘ └──────────────┘
|
||||
│
|
||||
┌───────────┐ ┌──────────────┐
|
||||
│ Volcengine │ │ Feishu Bot │
|
||||
└───────────┘ └──────────────┘
|
||||
```
|
||||
|
||||
@@ -106,15 +114,21 @@ main/
|
||||
│
|
||||
├── wifi/
|
||||
│ ├── wifi_manager.h WiFi STA lifecycle API
|
||||
│ └── wifi_manager.c Event handler, exponential backoff
|
||||
│ └── wifi_manager.c Event handler, exponential backoff (timer-based retry)
|
||||
│
|
||||
├── telegram/
|
||||
│ ├── telegram_bot.h Bot init/start, send_message API
|
||||
│ └── telegram_bot.c Long polling loop, JSON parsing, message splitting
|
||||
├── channels/
|
||||
│ ├── telegram/
|
||||
│ │ ├── telegram_bot.h Bot init/start, send_message API
|
||||
│ │ └── telegram_bot.c Long polling loop, JSON parsing, message splitting
|
||||
│ └── feishu/
|
||||
│ ├── feishu_bot.h Feishu bot API
|
||||
│ └── feishu_bot.c WebSocket event handling, message send/recv
|
||||
│
|
||||
├── llm/
|
||||
│ ├── llm_proxy.h llm_chat() + llm_chat_tools() API, tool_use types
|
||||
│ └── llm_proxy.c Anthropic Messages API (non-streaming), tool_use parsing
|
||||
│ ├── llm_proxy.c Multi-provider LLM (Anthropic + OpenAI-compatible)
|
||||
│ ├── llm_provider.h Provider registry + configuration API
|
||||
│ └── llm_provider.c Provider configs: anthropic, openai, siliconflow, volcengine
|
||||
│
|
||||
├── agent/
|
||||
│ ├── agent_loop.h Agent task init/start
|
||||
@@ -125,8 +139,17 @@ main/
|
||||
├── tools/
|
||||
│ ├── tool_registry.h Tool definition struct, register/dispatch API
|
||||
│ ├── tool_registry.c Tool registration, JSON schema builder, dispatch by name
|
||||
│ ├── tool_web_search.h Web search tool API
|
||||
│ └── tool_web_search.c Brave Search API via HTTPS (direct + proxy)
|
||||
│ ├── tool_web_search.h Web search tool API (Tavily + Brave)
|
||||
│ ├── tool_web_search.c Brave/Tavily Search API via HTTPS
|
||||
│ ├── tool_get_time.h Time tool API
|
||||
│ ├── tool_get_time.c HTTP Date header parsing for time sync
|
||||
│ ├── tool_cron.h Cron tool API
|
||||
│ ├── tool_cron.c Cron job management
|
||||
│ ├── tool_files.h File tool API
|
||||
│ ├── tool_files.c read/write/edit/list files on SPIFFS
|
||||
│ ├── tool_gpio.h GPIO tool API
|
||||
│ ├── tool_gpio.c GPIO read/write
|
||||
│ └── gpio_policy.c GPIO pin allowlist policy
|
||||
│
|
||||
├── memory/
|
||||
│ ├── memory_store.h Long-term + daily memory API
|
||||
@@ -140,12 +163,29 @@ main/
|
||||
│
|
||||
├── proxy/
|
||||
│ ├── http_proxy.h Proxy connection API
|
||||
│ └── http_proxy.c HTTP CONNECT tunnel + TLS via esp_tls
|
||||
│ └── http_proxy.c HTTP CONNECT tunnel + SOCKS5 tunnel + TLS
|
||||
│
|
||||
├── cli/
|
||||
│ ├── serial_cli.h CLI init API
|
||||
│ └── serial_cli.c esp_console REPL with debug/maintenance commands
|
||||
│
|
||||
├── cron/
|
||||
│ ├── cron_service.h Cron job API
|
||||
│ └── cron_service.c Cron scheduler, job persistence, execution
|
||||
│
|
||||
├── heartbeat/
|
||||
│ ├── heartbeat.h Heartbeat API
|
||||
│ └── heartbeat.c Periodic heartbeat messages
|
||||
│
|
||||
├── onboard/
|
||||
│ ├── wifi_onboard.h WiFi onboarding portal API
|
||||
│ ├── wifi_onboard.c Captive portal + Soft AP + HTTP config page
|
||||
│ └── onboard_html.h Embedded HTML/CSS/JS for setup page
|
||||
│
|
||||
├── skills/
|
||||
│ ├── skill_loader.h Skill loader API
|
||||
│ └── skill_loader.c Load skill files from SPIFFS
|
||||
│
|
||||
└── ota/
|
||||
├── ota_manager.h OTA update API
|
||||
└── ota_manager.c esp_https_ota wrapper
|
||||
@@ -158,9 +198,13 @@ main/
|
||||
| Task | Core | Priority | Stack | Description |
|
||||
|--------------------|------|----------|--------|--------------------------------------|
|
||||
| `tg_poll` | 0 | 5 | 12 KB | Telegram long polling (30s timeout) |
|
||||
| `agent_loop` | 1 | 6 | 12 KB | Message processing + Claude API call |
|
||||
| `outbound` | 0 | 5 | 8 KB | Route responses to Telegram / WS |
|
||||
| `feishu_ws` | 0 | 5 | 12 KB | Feishu WebSocket event handling |
|
||||
| `agent_loop` | 1 | 6 | 24 KB | Message processing + LLM API call |
|
||||
| `outbound` | 0 | 5 | 12 KB | Route responses to channels |
|
||||
| `serial_cli` | 0 | 3 | 4 KB | USB serial console REPL |
|
||||
| `onboard_dns` | 0 | 5 | 4 KB | DNS hijack for captive portal |
|
||||
| `cron_check` | 0 | 4 | 4 KB | Cron job scheduler |
|
||||
| `heartbeat` | 0 | 4 | 4 KB | Periodic heartbeat |
|
||||
| httpd (internal) | 0 | 5 | — | WebSocket server (esp_http_server) |
|
||||
| wifi_event (IDF) | 0 | 8 | — | WiFi event handling (ESP-IDF) |
|
||||
|
||||
@@ -225,20 +269,66 @@ Session files are JSONL (one JSON object per line):
|
||||
|
||||
## Configuration
|
||||
|
||||
All configuration is done exclusively through `mimi_secrets.h` at build time. There is no runtime configuration — changing any setting requires `idf.py fullclean && idf.py build`.
|
||||
Configuration uses a multi-layer priority system:
|
||||
|
||||
### Build-time (`mimi_secrets.h`)
|
||||
Highest priority. Set in `mimi_secrets.h` (copy from `mimi_secrets.h.example`).
|
||||
|
||||
| Define | Description |
|
||||
|------------------------------|-----------------------------------------|
|
||||
|-------------------------------------|--------------------------------------------|
|
||||
| `MIMI_SECRET_WIFI_SSID` | WiFi SSID |
|
||||
| `MIMI_SECRET_WIFI_PASS` | WiFi password |
|
||||
| `MIMI_SECRET_TG_TOKEN` | Telegram Bot API token |
|
||||
| `MIMI_SECRET_API_KEY` | Anthropic API key |
|
||||
| `MIMI_SECRET_MODEL` | Model ID (default: claude-opus-4-6) |
|
||||
| `MIMI_SECRET_FEISHU_APP_ID` | Feishu App ID |
|
||||
| `MIMI_SECRET_FEISHU_APP_SECRET` | Feishu App Secret |
|
||||
| `MIMI_SECRET_API_KEY` | Generic LLM API key (fallback) |
|
||||
| `MIMI_SECRET_MODEL` | Model ID (default: claude-opus-4-5) |
|
||||
| `MIMI_SECRET_MODEL_PROVIDER` | LLM provider: anthropic/openai/siliconflow/volcengine |
|
||||
| `MIMI_SECRET_ANTHROPIC_API_KEY` | Anthropic-specific API key |
|
||||
| `MIMI_SECRET_OPENAI_API_KEY` | OpenAI-specific API key |
|
||||
| `MIMI_SECRET_SILICONFLOW_API_KEY` | SiliconFlow (硅基流动) API key |
|
||||
| `MIMI_SECRET_SILICONFLOW_BASE_URL` | SiliconFlow Base URL |
|
||||
| `MIMI_SECRET_VOLCENGINE_API_KEY` | Volcengine (火山引擎) API key |
|
||||
| `MIMI_SECRET_VOLCENGINE_BASE_URL` | Volcengine Base URL |
|
||||
| `MIMI_SECRET_PROXY_HOST` | HTTP proxy hostname/IP (optional) |
|
||||
| `MIMI_SECRET_PROXY_PORT` | HTTP proxy port (optional) |
|
||||
| `MIMI_SECRET_PROXY_TYPE` | Proxy type: http/socks5 |
|
||||
| `MIMI_SECRET_SEARCH_KEY` | Brave Search API key (optional) |
|
||||
| `MIMI_SECRET_TAVILY_KEY` | Tavily Search API key (optional) |
|
||||
|
||||
NVS is still initialized (required by ESP-IDF WiFi internals) but is not used for application configuration.
|
||||
### Runtime (NVS + Onboard Portal)
|
||||
Set via serial CLI or the onboard configuration portal (192.168.4.1).
|
||||
|
||||
| CLI Command | Description |
|
||||
|------------------------------------|--------------------------------------|
|
||||
| `wifi_set <SSID> <Password>` | Set WiFi credentials |
|
||||
| `set_tg_token <Token>` | Set Telegram Bot token |
|
||||
| `set_api_key <Key>` | Set generic LLM API key |
|
||||
| `set_model_provider <Provider>` | Set provider: anthropic/openai/siliconflow/volcengine |
|
||||
| `set_model <Model>` | Set model name |
|
||||
| `set_siliconflow_key <Key>` | Set SiliconFlow-specific API key |
|
||||
| `set_siliconflow_url <URL>` | Set SiliconFlow Base URL |
|
||||
| `set_volcengine_key <Key>` | Set Volcengine-specific API key |
|
||||
| `set_volcengine_url <URL>` | Set Volcengine Base URL |
|
||||
| `config_show` | Show current config (masked) |
|
||||
| `config_reset` | Reset to build-time defaults |
|
||||
|
||||
### Priority Order (highest → lowest)
|
||||
1. NVS runtime config (CLI or onboard portal)
|
||||
2. Provider-specific NVS key (e.g. `siliconflow_api_key`)
|
||||
3. Provider-specific build-time config (e.g. `MIMI_SECRET_SILICONFLOW_API_KEY`)
|
||||
4. Generic build-time config (`MIMI_SECRET_API_KEY`, `MIMI_SECRET_MODEL_PROVIDER`)
|
||||
|
||||
## Supported LLM Providers
|
||||
|
||||
| Provider | API Compatible | Default Endpoint |
|
||||
|-------------|----------------|-------------------------------------------------------|
|
||||
| anthropic | Anthropic | https://api.anthropic.com/v1/messages |
|
||||
| openai | OpenAI | https://api.openai.com/v1/chat/completions |
|
||||
| siliconflow | OpenAI | https://api.siliconflow.cn/v1/chat/completions |
|
||||
| volcengine | OpenAI | https://ark.cn-beijing.volces.com/api/v3/chat/completions |
|
||||
|
||||
All OpenAI-compatible providers use Bearer token authentication and the same message format.
|
||||
|
||||
---
|
||||
|
||||
|
||||
82
docs/ESP-IDF-V6-MIGRATION.md
Normal file
82
docs/ESP-IDF-V6-MIGRATION.md
Normal file
@@ -0,0 +1,82 @@
|
||||
# ESP-IDF v6.0 编译适配记录
|
||||
|
||||
> 日期:2026-03-31
|
||||
> 目标芯片:ESP32-S3
|
||||
> ESP-IDF 版本:v6.0
|
||||
> 问题:从旧版本迁移到 ESP-IDF v6.0 后编译失败,存在多处头文件缺失、配置错误、CMakeLists 遗漏
|
||||
|
||||
---
|
||||
|
||||
## 问题清单与修复
|
||||
|
||||
### 1. Flash 大小配置错误
|
||||
|
||||
**错误信息:**
|
||||
```
|
||||
Partitions tables occupies 16.0MB of flash which does not fit in configured flash size 2MB
|
||||
```
|
||||
|
||||
**修复:** `sdkconfig` 中 flash 大小从 2MB 改为 16MB
|
||||
- `CONFIG_ESPTOOLPY_FLASHSIZE_2MB=y` → `CONFIG_ESPTOOLPY_FLASHSIZE_16MB=y`
|
||||
- `CONFIG_ESPTOOLPY_FLASHSIZE="2MB"` → `CONFIG_ESPTOOLPY_FLASHSIZE="16MB"`
|
||||
|
||||
### 2. WiFi 断开原因码未定义
|
||||
|
||||
**错误信息:**
|
||||
```
|
||||
error: 'WIFI_REASON_ASSOC_EXPIRE' undeclared
|
||||
```
|
||||
|
||||
**修复:** `main/wifi/wifi_manager.c` — 为所有 reason code 添加 `#ifdef` 保护(ESP-IDF v6.0 移除了部分原因码)
|
||||
|
||||
### 3. CMakeLists.txt 缺少源文件
|
||||
|
||||
**修复:** `main/CMakeLists.txt`
|
||||
- 添加 `"ota/ota_manager.c"` 到 SRCS 列表
|
||||
|
||||
### 4. 头文件缺失(共修复 16 处)
|
||||
|
||||
| 文件 | 缺失头文件 | 使用符号 |
|
||||
|------|-----------|---------|
|
||||
| `main/cli/serial_cli.c` | `llm/llm_provider.h` | `llm_provider_set_api_key` |
|
||||
| `main/llm/llm_provider.c` | `esp_http_client.h` | `esp_http_client_set_header` |
|
||||
| `main/bus/message_bus.c` | `freertos/FreeRTOS.h`, `freertos/queue.h` | `xQueueCreate`, `QueueHandle_t` |
|
||||
| `main/wifi/wifi_manager.c` | `esp_event.h` | `esp_event_handler_instance_register` |
|
||||
| `main/wifi/wifi_manager.c` | `freertos/FreeRTOS.h`, `freertos/task.h`, `freertos/event_groups.h` | `xEventGroupCreate`, `vTaskDelay` |
|
||||
| `main/ota/ota_manager.c` | `esp_system.h` | `esp_restart` |
|
||||
| `main/channels/telegram/telegram_bot.c` | `freertos/FreeRTOS.h`, `freertos/task.h` | `xTaskCreatePinnedToCore`, `vTaskDelay` |
|
||||
| `main/channels/telegram/telegram_bot.c` | `esp_err.h` | `esp_err_to_name` |
|
||||
| `main/tools/tool_registry.c` | `<stdlib.h>` | `free()` |
|
||||
| `main/proxy/http_proxy.c` | `<sys/time.h>` | `struct timeval` |
|
||||
| `main/gateway/ws_server.c` | `<stdint.h>` | `uint8_t` |
|
||||
|
||||
---
|
||||
|
||||
## ESP-IDF v6.0 API 兼容性验证
|
||||
|
||||
以下 API 在 v6.0 中**仍然可用**,无需修改:
|
||||
|
||||
| API | 位置 | 状态 |
|
||||
|-----|------|------|
|
||||
| `esp_spiffs_info()` | `esp_spiffs.h` | ✅ 存在 |
|
||||
| `esp_websocket_client_send_bin()` | `esp_websocket_client.h` | ✅ 存在 |
|
||||
| `esp_tls_set_conn_sockfd()` | `esp_tls.h` | ✅ 存在 |
|
||||
| `esp_tls_set_conn_state()` | `esp_tls.h` | ✅ 存在 |
|
||||
| `esp_console_new_repl_uart()` | `esp_console.h` | ✅ 存在 |
|
||||
| `esp_console_new_repl_usb_serial_jtag()` | `esp_console.h` | ✅ 存在 |
|
||||
| `esp_console_new_repl_usb_cdc()` | `esp_console.h` | ✅ 存在 |
|
||||
| `esp_https_ota()` + `esp_https_ota_config_t` | `esp_https_ota.h` | ✅ 存在 |
|
||||
| `esp_wifi_set_config()` | `esp_wifi.h` | ✅ 存在 |
|
||||
|
||||
---
|
||||
|
||||
## 烧录说明
|
||||
|
||||
ESP32-S3 烧录使用 **USB 口**(内置 USB Serial/JTAG 控制器):
|
||||
|
||||
```powershell
|
||||
idf.py -p COMx flash monitor
|
||||
```
|
||||
|
||||
- 插 **USB 口**(标记为 `USB`),不是 UART 口
|
||||
- 如遇连接失败,按住 **BOOT** 键再插线进入下载模式
|
||||
100
docs/WIFI_ONBOARDING_AP.md
Normal file
100
docs/WIFI_ONBOARDING_AP.md
Normal 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.
|
||||
@@ -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 |
|
||||
|
||||
@@ -6,6 +6,7 @@ idf_component_register(
|
||||
"channels/telegram/telegram_bot.c"
|
||||
"channels/feishu/feishu_bot.c"
|
||||
"llm/llm_proxy.c"
|
||||
"llm/llm_provider.c"
|
||||
"agent/agent_loop.c"
|
||||
"agent/context_builder.c"
|
||||
"memory/memory_store.c"
|
||||
@@ -19,12 +20,17 @@ idf_component_register(
|
||||
"tools/tool_cron.c"
|
||||
"tools/tool_web_search.c"
|
||||
"tools/tool_get_time.c"
|
||||
"tools/tool_set_timezone.c"
|
||||
"tools/tool_files.c"
|
||||
"tools/tool_gpio.c"
|
||||
"tools/gpio_policy.c"
|
||||
"skills/skill_loader.c"
|
||||
"onboard/wifi_onboard.c"
|
||||
"ota/ota_manager.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_https_ota esp_event cjson spiffs console vfs app_update esp-tls
|
||||
esp_timer esp_websocket_client esp_driver_gpio
|
||||
)
|
||||
|
||||
@@ -14,8 +14,12 @@ static size_t append_file(char *buf, size_t size, size_t offset, const char *pat
|
||||
FILE *f = fopen(path, "r");
|
||||
if (!f) return offset;
|
||||
|
||||
if (header && offset < size - 1) {
|
||||
offset += snprintf(buf + offset, size - offset, "\n## %s\n\n", header);
|
||||
if (offset >= size) return offset;
|
||||
|
||||
if (header) {
|
||||
int ret = snprintf(buf + offset, size - offset, "\n## %s\n\n", header);
|
||||
if (ret > 0) offset += (size_t)ret;
|
||||
if (offset >= size) { offset = size - 1; buf[offset] = '\0'; fclose(f); return offset; }
|
||||
}
|
||||
|
||||
size_t n = fread(buf + offset, 1, size - offset - 1, f);
|
||||
@@ -46,8 +50,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"
|
||||
@@ -72,23 +83,35 @@ esp_err_t context_build_system_prompt(char *buf, size_t size)
|
||||
/* Long-term memory */
|
||||
char mem_buf[4096];
|
||||
if (memory_read_long_term(mem_buf, sizeof(mem_buf)) == ESP_OK && mem_buf[0]) {
|
||||
off += snprintf(buf + off, size - off, "\n## Long-term Memory\n\n%s\n", mem_buf);
|
||||
if (off < size) {
|
||||
int ret = snprintf(buf + off, size - off, "\n## Long-term Memory\n\n%s\n", mem_buf);
|
||||
if (ret > 0) off += (size_t)ret;
|
||||
if (off >= size) off = size - 1;
|
||||
}
|
||||
}
|
||||
|
||||
/* Recent daily notes (last 3 days) */
|
||||
char recent_buf[4096];
|
||||
if (memory_read_recent(recent_buf, sizeof(recent_buf), 3) == ESP_OK && recent_buf[0]) {
|
||||
off += snprintf(buf + off, size - off, "\n## Recent Notes\n\n%s\n", recent_buf);
|
||||
if (off < size) {
|
||||
int ret = snprintf(buf + off, size - off, "\n## Recent Notes\n\n%s\n", recent_buf);
|
||||
if (ret > 0) off += (size_t)ret;
|
||||
if (off >= size) off = size - 1;
|
||||
}
|
||||
}
|
||||
|
||||
/* Skills */
|
||||
char skills_buf[2048];
|
||||
size_t skills_len = skill_loader_build_summary(skills_buf, sizeof(skills_buf));
|
||||
if (skills_len > 0) {
|
||||
off += snprintf(buf + off, size - off,
|
||||
if (off < size) {
|
||||
int ret = snprintf(buf + off, size - off,
|
||||
"\n## Available Skills\n\n"
|
||||
"Available skills (use read_file to load full instructions):\n%s\n",
|
||||
skills_buf);
|
||||
if (ret > 0) off += (size_t)ret;
|
||||
if (off >= size) off = size - 1;
|
||||
}
|
||||
}
|
||||
|
||||
ESP_LOGI(TAG, "System prompt built: %d bytes", (int)off);
|
||||
|
||||
@@ -1,6 +1,8 @@
|
||||
#include "message_bus.h"
|
||||
#include "mimi_config.h"
|
||||
#include "esp_log.h"
|
||||
#include "freertos/FreeRTOS.h"
|
||||
#include "freertos/queue.h"
|
||||
#include <string.h>
|
||||
|
||||
static const char *TAG = "bus";
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -583,6 +583,7 @@ static void feishu_ws_event_handler(void *arg, esp_event_base_t base, int32_t ev
|
||||
} else if (event_id == WEBSOCKET_EVENT_DISCONNECTED) {
|
||||
s_ws_connected = false;
|
||||
ESP_LOGW(TAG, "Feishu WS disconnected");
|
||||
if (rx_buf) { free(rx_buf); rx_buf = NULL; rx_cap = 0; }
|
||||
} else if (event_id == WEBSOCKET_EVENT_DATA) {
|
||||
if (e->op_code != WS_TRANSPORT_OPCODES_BINARY) return;
|
||||
size_t need = e->payload_offset + e->data_len;
|
||||
|
||||
@@ -7,11 +7,14 @@
|
||||
#include <stdlib.h>
|
||||
#include <stdbool.h>
|
||||
#include "esp_log.h"
|
||||
#include "esp_err.h"
|
||||
#include "esp_timer.h"
|
||||
#include "esp_http_client.h"
|
||||
#include "esp_crt_bundle.h"
|
||||
#include "nvs.h"
|
||||
#include "cJSON.h"
|
||||
#include "freertos/FreeRTOS.h"
|
||||
#include "freertos/task.h"
|
||||
|
||||
static const char *TAG = "telegram";
|
||||
|
||||
|
||||
@@ -4,6 +4,7 @@
|
||||
#include "channels/telegram/telegram_bot.h"
|
||||
#include "channels/feishu/feishu_bot.h"
|
||||
#include "llm/llm_proxy.h"
|
||||
#include "llm/llm_provider.h"
|
||||
#include "memory/memory_store.h"
|
||||
#include "memory/session_mgr.h"
|
||||
#include "proxy/http_proxy.h"
|
||||
@@ -16,6 +17,8 @@
|
||||
#include <string.h>
|
||||
#include <stdio.h>
|
||||
#include <ctype.h>
|
||||
#include <stdbool.h>
|
||||
#include <time.h>
|
||||
#include <dirent.h>
|
||||
#include "esp_log.h"
|
||||
#include "esp_console.h"
|
||||
@@ -171,6 +174,80 @@ static int cmd_set_model_provider(int argc, char **argv)
|
||||
return 0;
|
||||
}
|
||||
|
||||
/* --- set_siliconflow_key command --- */
|
||||
static struct {
|
||||
struct arg_str *key;
|
||||
struct arg_end *end;
|
||||
} siliconflow_key_args;
|
||||
|
||||
static int cmd_set_siliconflow_key(int argc, char **argv)
|
||||
{
|
||||
int nerrors = arg_parse(argc, argv, (void **)&siliconflow_key_args);
|
||||
if (nerrors != 0) {
|
||||
arg_print_errors(stderr, siliconflow_key_args.end, argv[0]);
|
||||
return 1;
|
||||
}
|
||||
llm_set_api_key(siliconflow_key_args.key->sval[0]);
|
||||
llm_provider_set_api_key("siliconflow", siliconflow_key_args.key->sval[0]);
|
||||
printf("SiliconFlow API key saved.\n");
|
||||
return 0;
|
||||
}
|
||||
|
||||
/* --- set_siliconflow_url command --- */
|
||||
static struct {
|
||||
struct arg_str *url;
|
||||
struct arg_end *end;
|
||||
} siliconflow_url_args;
|
||||
|
||||
static int cmd_set_siliconflow_url(int argc, char **argv)
|
||||
{
|
||||
int nerrors = arg_parse(argc, argv, (void **)&siliconflow_url_args);
|
||||
if (nerrors != 0) {
|
||||
arg_print_errors(stderr, siliconflow_url_args.end, argv[0]);
|
||||
return 1;
|
||||
}
|
||||
llm_set_base_url("siliconflow", siliconflow_url_args.url->sval[0]);
|
||||
printf("SiliconFlow Base URL saved.\n");
|
||||
return 0;
|
||||
}
|
||||
|
||||
/* --- set_volcengine_key command --- */
|
||||
static struct {
|
||||
struct arg_str *key;
|
||||
struct arg_end *end;
|
||||
} volcengine_key_args;
|
||||
|
||||
static int cmd_set_volcengine_key(int argc, char **argv)
|
||||
{
|
||||
int nerrors = arg_parse(argc, argv, (void **)&volcengine_key_args);
|
||||
if (nerrors != 0) {
|
||||
arg_print_errors(stderr, volcengine_key_args.end, argv[0]);
|
||||
return 1;
|
||||
}
|
||||
llm_set_api_key(volcengine_key_args.key->sval[0]);
|
||||
llm_provider_set_api_key("volcengine", volcengine_key_args.key->sval[0]);
|
||||
printf("Volcengine API key saved.\n");
|
||||
return 0;
|
||||
}
|
||||
|
||||
/* --- set_volcengine_url command --- */
|
||||
static struct {
|
||||
struct arg_str *url;
|
||||
struct arg_end *end;
|
||||
} volcengine_url_args;
|
||||
|
||||
static int cmd_set_volcengine_url(int argc, char **argv)
|
||||
{
|
||||
int nerrors = arg_parse(argc, argv, (void **)&volcengine_url_args);
|
||||
if (nerrors != 0) {
|
||||
arg_print_errors(stderr, volcengine_url_args.end, argv[0]);
|
||||
return 1;
|
||||
}
|
||||
llm_set_base_url("volcengine", volcengine_url_args.url->sval[0]);
|
||||
printf("Volcengine Base URL saved.\n");
|
||||
return 0;
|
||||
}
|
||||
|
||||
/* --- memory_read command --- */
|
||||
static int cmd_memory_read(int argc, char **argv)
|
||||
{
|
||||
@@ -528,6 +605,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");
|
||||
@@ -537,10 +640,27 @@ static int cmd_config_show(int argc, char **argv)
|
||||
print_config("API Key", MIMI_NVS_LLM, MIMI_NVS_KEY_API_KEY, MIMI_SECRET_API_KEY, true);
|
||||
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);
|
||||
|
||||
/* Provider-specific configurations */
|
||||
printf(" --- Provider-specific configs ---\n");
|
||||
print_config("Anthropic Key", MIMI_NVS_LLM, MIMI_NVS_KEY_ANTHROPIC_API_KEY, "", true);
|
||||
print_config("Anthropic URL", MIMI_NVS_LLM, MIMI_NVS_KEY_ANTHROPIC_BASE_URL, "", false);
|
||||
print_config("OpenAI Key", MIMI_NVS_LLM, MIMI_NVS_KEY_OPENAI_API_KEY, "", true);
|
||||
print_config("OpenAI URL", MIMI_NVS_LLM, MIMI_NVS_KEY_OPENAI_BASE_URL, "", false);
|
||||
print_config("SiliconFlow Key", MIMI_NVS_LLM, MIMI_NVS_KEY_SILICONFLOW_API_KEY, "", true);
|
||||
print_config("SiliconFlow URL", MIMI_NVS_LLM, MIMI_NVS_KEY_SILICONFLOW_BASE_URL, "", false);
|
||||
print_config("Volcengine Key", MIMI_NVS_LLM, MIMI_NVS_KEY_VOLCENGINE_API_KEY, "", true);
|
||||
print_config("Volcengine URL", MIMI_NVS_LLM, MIMI_NVS_KEY_VOLCENGINE_BASE_URL, "", 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);
|
||||
|
||||
/* System configs */
|
||||
printf(" --- System configs ---\n");
|
||||
print_config("Timezone", "system_config", MIMI_NVS_KEY_TIMEZONE, MIMI_TIMEZONE, false);
|
||||
|
||||
printf("=============================\n");
|
||||
return 0;
|
||||
}
|
||||
@@ -563,6 +683,87 @@ static int cmd_config_reset(int argc, char **argv)
|
||||
return 0;
|
||||
}
|
||||
|
||||
/* --- set_timezone command --- */
|
||||
static struct {
|
||||
struct arg_str *tz;
|
||||
struct arg_end *end;
|
||||
} timezone_args;
|
||||
|
||||
static int cmd_set_timezone(int argc, char **argv)
|
||||
{
|
||||
int nerrors = arg_parse(argc, argv, (void **)&timezone_args);
|
||||
if (nerrors != 0) {
|
||||
arg_print_errors(stderr, timezone_args.end, argv[0]);
|
||||
return 1;
|
||||
}
|
||||
|
||||
const char *tz_str = timezone_args.tz->sval[0];
|
||||
nvs_handle_t nvs;
|
||||
esp_err_t err = nvs_open("system_config", NVS_READWRITE, &nvs);
|
||||
if (err != ESP_OK) {
|
||||
printf("Failed to open NVS: %s\n", esp_err_to_name(err));
|
||||
return 1;
|
||||
}
|
||||
|
||||
err = nvs_set_str(nvs, MIMI_NVS_KEY_TIMEZONE, tz_str);
|
||||
if (err != ESP_OK) {
|
||||
printf("Failed to save timezone: %s\n", esp_err_to_name(err));
|
||||
nvs_close(nvs);
|
||||
return 1;
|
||||
}
|
||||
|
||||
err = nvs_commit(nvs);
|
||||
nvs_close(nvs);
|
||||
|
||||
if (err != ESP_OK) {
|
||||
printf("Failed to commit NVS: %s\n", esp_err_to_name(err));
|
||||
return 1;
|
||||
}
|
||||
|
||||
setenv("TZ", tz_str, 1);
|
||||
tzset();
|
||||
|
||||
time_t now = time(NULL);
|
||||
struct tm tm_now;
|
||||
localtime_r(&now, &tm_now);
|
||||
char time_str[64];
|
||||
strftime(time_str, sizeof(time_str), "%Y-%m-%d %H:%M:%S %Z", &tm_now);
|
||||
|
||||
printf("Timezone set to '%s'. Current time: %s. Restart to apply permanently.\n", tz_str, time_str);
|
||||
return 0;
|
||||
}
|
||||
|
||||
/* --- timezone_show command --- */
|
||||
static int cmd_timezone_show(int argc, char **argv)
|
||||
{
|
||||
(void)argc;
|
||||
(void)argv;
|
||||
|
||||
char nvs_val[64] = {0};
|
||||
const char *source = "build";
|
||||
const char *display = MIMI_TIMEZONE;
|
||||
|
||||
nvs_handle_t nvs;
|
||||
if (nvs_open("system_config", NVS_READONLY, &nvs) == ESP_OK) {
|
||||
size_t len = sizeof(nvs_val);
|
||||
if (nvs_get_str(nvs, MIMI_NVS_KEY_TIMEZONE, nvs_val, &len) == ESP_OK && nvs_val[0]) {
|
||||
source = "NVS";
|
||||
display = nvs_val;
|
||||
}
|
||||
nvs_close(nvs);
|
||||
}
|
||||
|
||||
printf("Current timezone: %s [%s]\n", display, source);
|
||||
|
||||
time_t now = time(NULL);
|
||||
struct tm tm_now;
|
||||
localtime_r(&now, &tm_now);
|
||||
char time_str[64];
|
||||
strftime(time_str, sizeof(time_str), "%Y-%m-%d %H:%M:%S %Z (%A)", &tm_now);
|
||||
printf("Local time: %s\n", time_str);
|
||||
return 0;
|
||||
}
|
||||
|
||||
/* --- heartbeat_trigger command --- */
|
||||
static int cmd_heartbeat_trigger(int argc, char **argv)
|
||||
{
|
||||
@@ -623,13 +824,22 @@ typedef struct {
|
||||
size_t output_size;
|
||||
esp_err_t err;
|
||||
SemaphoreHandle_t done;
|
||||
bool timed_out;
|
||||
} web_search_task_ctx_t;
|
||||
|
||||
static void web_search_task(void *arg)
|
||||
{
|
||||
web_search_task_ctx_t *task_ctx = (web_search_task_ctx_t *)arg;
|
||||
task_ctx->err = tool_web_search_execute(task_ctx->input_json, task_ctx->output, task_ctx->output_size);
|
||||
if (!task_ctx->timed_out) {
|
||||
xSemaphoreGive(task_ctx->done);
|
||||
} else {
|
||||
/* Main thread timed out and freed ctx, so we must clean up ourselves */
|
||||
free((void *)task_ctx->input_json);
|
||||
free(task_ctx->output);
|
||||
vSemaphoreDelete(task_ctx->done);
|
||||
free(task_ctx);
|
||||
}
|
||||
vTaskDelete(NULL);
|
||||
}
|
||||
|
||||
@@ -725,10 +935,8 @@ static int cmd_web_search(int argc, char **argv)
|
||||
|
||||
if (xSemaphoreTake(ctx->done, pdMS_TO_TICKS(45000)) != pdTRUE) {
|
||||
printf("web_search status: timeout\n");
|
||||
vSemaphoreDelete(ctx->done);
|
||||
free(input_copy);
|
||||
free(ctx);
|
||||
free(output);
|
||||
ctx->timed_out = true;
|
||||
/* Task will clean up ctx, input_copy, output, and done on its own */
|
||||
return 1;
|
||||
}
|
||||
esp_err_t err = ctx->err;
|
||||
@@ -861,7 +1069,7 @@ esp_err_t serial_cli_init(void)
|
||||
esp_console_cmd_register(&model_cmd);
|
||||
|
||||
/* set_model_provider */
|
||||
provider_args.provider = arg_str1(NULL, NULL, "<provider>", "Model provider (anthropic|openai)");
|
||||
provider_args.provider = arg_str1(NULL, NULL, "<provider>", "Model provider (anthropic|openai|siliconflow|volcengine)");
|
||||
provider_args.end = arg_end(1);
|
||||
esp_console_cmd_t provider_cmd = {
|
||||
.command = "set_model_provider",
|
||||
@@ -871,6 +1079,50 @@ esp_err_t serial_cli_init(void)
|
||||
};
|
||||
esp_console_cmd_register(&provider_cmd);
|
||||
|
||||
/* set_siliconflow_key */
|
||||
siliconflow_key_args.key = arg_str1(NULL, NULL, "<key>", "SiliconFlow API key");
|
||||
siliconflow_key_args.end = arg_end(1);
|
||||
esp_console_cmd_t siliconflow_key_cmd = {
|
||||
.command = "set_siliconflow_key",
|
||||
.help = "Set SiliconFlow API key",
|
||||
.func = &cmd_set_siliconflow_key,
|
||||
.argtable = &siliconflow_key_args,
|
||||
};
|
||||
esp_console_cmd_register(&siliconflow_key_cmd);
|
||||
|
||||
/* set_siliconflow_url */
|
||||
siliconflow_url_args.url = arg_str1(NULL, NULL, "<url>", "SiliconFlow Base URL");
|
||||
siliconflow_url_args.end = arg_end(1);
|
||||
esp_console_cmd_t siliconflow_url_cmd = {
|
||||
.command = "set_siliconflow_url",
|
||||
.help = "Set SiliconFlow Base URL",
|
||||
.func = &cmd_set_siliconflow_url,
|
||||
.argtable = &siliconflow_url_args,
|
||||
};
|
||||
esp_console_cmd_register(&siliconflow_url_cmd);
|
||||
|
||||
/* set_volcengine_key */
|
||||
volcengine_key_args.key = arg_str1(NULL, NULL, "<key>", "Volcengine API key");
|
||||
volcengine_key_args.end = arg_end(1);
|
||||
esp_console_cmd_t volcengine_key_cmd = {
|
||||
.command = "set_volcengine_key",
|
||||
.help = "Set Volcengine API key",
|
||||
.func = &cmd_set_volcengine_key,
|
||||
.argtable = &volcengine_key_args,
|
||||
};
|
||||
esp_console_cmd_register(&volcengine_key_cmd);
|
||||
|
||||
/* set_volcengine_url */
|
||||
volcengine_url_args.url = arg_str1(NULL, NULL, "<url>", "Volcengine Base URL");
|
||||
volcengine_url_args.end = arg_end(1);
|
||||
esp_console_cmd_t volcengine_url_cmd = {
|
||||
.command = "set_volcengine_url",
|
||||
.help = "Set Volcengine Base URL",
|
||||
.func = &cmd_set_volcengine_url,
|
||||
.argtable = &volcengine_url_args,
|
||||
};
|
||||
esp_console_cmd_register(&volcengine_url_cmd);
|
||||
|
||||
/* skill_list */
|
||||
esp_console_cmd_t skill_list_cmd = {
|
||||
.command = "skill_list",
|
||||
@@ -1006,6 +1258,25 @@ esp_err_t serial_cli_init(void)
|
||||
};
|
||||
esp_console_cmd_register(&config_reset_cmd);
|
||||
|
||||
/* set_timezone */
|
||||
timezone_args.tz = arg_str1(NULL, NULL, "<timezone>", "Timezone (e.g. CST-8, Asia/Shanghai)");
|
||||
timezone_args.end = arg_end(1);
|
||||
esp_console_cmd_t set_timezone_cmd = {
|
||||
.command = "set_timezone",
|
||||
.help = "Set system timezone (e.g. set_timezone CST-8)",
|
||||
.func = &cmd_set_timezone,
|
||||
.argtable = &timezone_args,
|
||||
};
|
||||
esp_console_cmd_register(&set_timezone_cmd);
|
||||
|
||||
/* timezone_show */
|
||||
esp_console_cmd_t timezone_show_cmd = {
|
||||
.command = "timezone_show",
|
||||
.help = "Show current timezone and local time",
|
||||
.func = &cmd_timezone_show,
|
||||
};
|
||||
esp_console_cmd_register(&timezone_show_cmd);
|
||||
|
||||
/* heartbeat_trigger */
|
||||
esp_console_cmd_t heartbeat_cmd = {
|
||||
.command = "heartbeat_trigger",
|
||||
|
||||
@@ -4,6 +4,7 @@
|
||||
|
||||
#include <stdio.h>
|
||||
#include <stdlib.h>
|
||||
#include <stdbool.h>
|
||||
#include <string.h>
|
||||
#include <time.h>
|
||||
#include "freertos/FreeRTOS.h"
|
||||
|
||||
@@ -4,6 +4,9 @@
|
||||
|
||||
#include <string.h>
|
||||
#include <stdlib.h>
|
||||
#include <stdbool.h>
|
||||
#include <stdint.h>
|
||||
#include <stdio.h>
|
||||
#include "esp_log.h"
|
||||
#include "esp_http_server.h"
|
||||
#include "cJSON.h"
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
dependencies:
|
||||
## Required IDF version
|
||||
idf:
|
||||
version: '>=5.5.0,<5.6.0'
|
||||
version: ">=5.5.0,<7.0.0"
|
||||
# # Put list of dependencies here
|
||||
# # For components maintained by Espressif:
|
||||
# component: "~1.0.0"
|
||||
@@ -15,3 +15,4 @@ dependencies:
|
||||
# # All dependencies of `main` are public by default.
|
||||
# public: true
|
||||
espressif/esp_websocket_client: ^1.4.0
|
||||
espressif/cjson: ">=1.0.0"
|
||||
|
||||
300
main/llm/llm_provider.c
Normal file
300
main/llm/llm_provider.c
Normal file
@@ -0,0 +1,300 @@
|
||||
#include "llm_provider.h"
|
||||
#include "mimi_config.h"
|
||||
#include "nvs.h"
|
||||
|
||||
#include <string.h>
|
||||
#include <stdlib.h>
|
||||
#include <stdio.h>
|
||||
#include <stdbool.h>
|
||||
#include "esp_log.h"
|
||||
#include "esp_err.h"
|
||||
#include "esp_http_client.h"
|
||||
|
||||
static const char *TAG = "llm_provider";
|
||||
|
||||
#define LLM_API_KEY_MAX_LEN 320
|
||||
|
||||
/* Provider registry - all supported providers */
|
||||
const llm_provider_config_t llm_providers[] = {
|
||||
{
|
||||
.name = "anthropic",
|
||||
.default_api_url = MIMI_LLM_API_URL,
|
||||
.default_host = "api.anthropic.com",
|
||||
.default_path = "/v1/messages",
|
||||
.is_openai_compatible = false,
|
||||
},
|
||||
{
|
||||
.name = "openai",
|
||||
.default_api_url = MIMI_OPENAI_API_URL,
|
||||
.default_host = "api.openai.com",
|
||||
.default_path = "/v1/chat/completions",
|
||||
.is_openai_compatible = true,
|
||||
},
|
||||
{
|
||||
.name = "siliconflow",
|
||||
.default_api_url = MIMI_SILICONFLOW_API_URL,
|
||||
.default_host = "api.siliconflow.cn",
|
||||
.default_path = "/v1/chat/completions",
|
||||
.is_openai_compatible = true,
|
||||
},
|
||||
{
|
||||
.name = "volcengine",
|
||||
.default_api_url = MIMI_VOLCENGINE_API_URL,
|
||||
.default_host = "ark.cn-beijing.volces.com",
|
||||
.default_path = "/v1/chat/completions",
|
||||
.is_openai_compatible = true,
|
||||
},
|
||||
};
|
||||
|
||||
const int llm_provider_count = sizeof(llm_providers) / sizeof(llm_providers[0]);
|
||||
|
||||
/* Current provider state */
|
||||
static const llm_provider_config_t *s_current_provider = &llm_providers[0]; /* Default to anthropic */
|
||||
static char s_api_key[LLM_API_KEY_MAX_LEN] = {0};
|
||||
static char s_base_url[256] = {0};
|
||||
|
||||
/* Helper function to get NVS key for provider API key */
|
||||
static const char *get_provider_api_key_nvs_key(const char *provider_name) {
|
||||
if (strcmp(provider_name, "anthropic") == 0) return MIMI_NVS_KEY_ANTHROPIC_API_KEY;
|
||||
if (strcmp(provider_name, "openai") == 0) return MIMI_NVS_KEY_OPENAI_API_KEY;
|
||||
if (strcmp(provider_name, "siliconflow") == 0) return MIMI_NVS_KEY_SILICONFLOW_API_KEY;
|
||||
if (strcmp(provider_name, "volcengine") == 0) return MIMI_NVS_KEY_VOLCENGINE_API_KEY;
|
||||
return NULL;
|
||||
}
|
||||
|
||||
/* Helper function to get NVS key for provider Base URL */
|
||||
static const char *get_provider_base_url_nvs_key(const char *provider_name) {
|
||||
if (strcmp(provider_name, "anthropic") == 0) return MIMI_NVS_KEY_ANTHROPIC_BASE_URL;
|
||||
if (strcmp(provider_name, "openai") == 0) return MIMI_NVS_KEY_OPENAI_BASE_URL;
|
||||
if (strcmp(provider_name, "siliconflow") == 0) return MIMI_NVS_KEY_SILICONFLOW_BASE_URL;
|
||||
if (strcmp(provider_name, "volcengine") == 0) return MIMI_NVS_KEY_VOLCENGINE_BASE_URL;
|
||||
return NULL;
|
||||
}
|
||||
|
||||
/* Find provider configuration by name */
|
||||
const llm_provider_config_t *llm_provider_find(const char *name) {
|
||||
if (!name) return NULL;
|
||||
|
||||
for (int i = 0; i < llm_provider_count; i++) {
|
||||
if (strcmp(llm_providers[i].name, name) == 0) {
|
||||
return &llm_providers[i];
|
||||
}
|
||||
}
|
||||
return NULL;
|
||||
}
|
||||
|
||||
/* Get current provider configuration */
|
||||
const llm_provider_config_t *llm_provider_current(void) {
|
||||
return s_current_provider;
|
||||
}
|
||||
|
||||
/* Set current provider by name */
|
||||
void llm_provider_set_current(const char *name) {
|
||||
const llm_provider_config_t *provider = llm_provider_find(name);
|
||||
if (provider) {
|
||||
s_current_provider = provider;
|
||||
/* Load provider-specific configuration */
|
||||
llm_provider_init();
|
||||
ESP_LOGI(TAG, "Current provider set to: %s", name);
|
||||
} else {
|
||||
ESP_LOGW(TAG, "Unknown provider: %s", name);
|
||||
}
|
||||
}
|
||||
|
||||
/* Get current provider name */
|
||||
const char *llm_provider_current_name(void) {
|
||||
return s_current_provider ? s_current_provider->name : "unknown";
|
||||
}
|
||||
|
||||
/* Check if current provider is OpenAI-compatible */
|
||||
bool llm_provider_is_openai_compatible(void) {
|
||||
return s_current_provider ? s_current_provider->is_openai_compatible : false;
|
||||
}
|
||||
|
||||
/* Get API URL for current provider (with dynamic Base URL support) */
|
||||
const char *llm_provider_api_url(void) {
|
||||
if (s_base_url[0] != '\0') {
|
||||
/* Use configured Base URL */
|
||||
static char full_url[512];
|
||||
snprintf(full_url, sizeof(full_url), "%s%s", s_base_url, s_current_provider->default_path);
|
||||
return full_url;
|
||||
}
|
||||
/* Use default API URL */
|
||||
return s_current_provider ? s_current_provider->default_api_url : "";
|
||||
}
|
||||
|
||||
/* Get hostname for current provider */
|
||||
const char *llm_provider_host(void) {
|
||||
if (s_base_url[0] != '\0') {
|
||||
/* Extract hostname from Base URL */
|
||||
static char hostname[256];
|
||||
const char *start = s_base_url;
|
||||
if (strncmp(start, "https://", 8) == 0) start += 8;
|
||||
else if (strncmp(start, "http://", 7) == 0) start += 7;
|
||||
|
||||
const char *end = strchr(start, '/');
|
||||
if (end) {
|
||||
size_t len = end - start;
|
||||
if (len < sizeof(hostname)) {
|
||||
memcpy(hostname, start, len);
|
||||
hostname[len] = '\0';
|
||||
return hostname;
|
||||
}
|
||||
}
|
||||
/* No path, copy whole string */
|
||||
strncpy(hostname, start, sizeof(hostname) - 1);
|
||||
hostname[sizeof(hostname) - 1] = '\0';
|
||||
return hostname;
|
||||
}
|
||||
/* Use default host */
|
||||
return s_current_provider ? s_current_provider->default_host : "";
|
||||
}
|
||||
|
||||
/* Get API path for current provider */
|
||||
const char *llm_provider_path(void) {
|
||||
return s_current_provider ? s_current_provider->default_path : "";
|
||||
}
|
||||
|
||||
/* Provider-specific API key management */
|
||||
void llm_provider_set_api_key(const char *provider_name, const char *api_key) {
|
||||
if (!provider_name || !api_key) return;
|
||||
|
||||
const char *nvs_key = get_provider_api_key_nvs_key(provider_name);
|
||||
if (!nvs_key) {
|
||||
ESP_LOGW(TAG, "No NVS key for provider: %s", provider_name);
|
||||
return;
|
||||
}
|
||||
|
||||
nvs_handle_t nvs;
|
||||
if (nvs_open(MIMI_NVS_LLM, NVS_READWRITE, &nvs) == ESP_OK) {
|
||||
nvs_set_str(nvs, nvs_key, api_key);
|
||||
nvs_commit(nvs);
|
||||
nvs_close(nvs);
|
||||
ESP_LOGI(TAG, "API key saved for provider: %s", provider_name);
|
||||
}
|
||||
|
||||
/* If this is the current provider, update in-memory key */
|
||||
if (strcmp(provider_name, s_current_provider->name) == 0) {
|
||||
strncpy(s_api_key, api_key, sizeof(s_api_key) - 1);
|
||||
s_api_key[sizeof(s_api_key) - 1] = '\0';
|
||||
}
|
||||
}
|
||||
|
||||
/* Provider-specific Base URL management */
|
||||
void llm_provider_set_base_url(const char *provider_name, const char *base_url) {
|
||||
if (!provider_name || !base_url) return;
|
||||
|
||||
const char *nvs_key = get_provider_base_url_nvs_key(provider_name);
|
||||
if (!nvs_key) {
|
||||
ESP_LOGW(TAG, "No NVS key for provider: %s", provider_name);
|
||||
return;
|
||||
}
|
||||
|
||||
nvs_handle_t nvs;
|
||||
if (nvs_open(MIMI_NVS_LLM, NVS_READWRITE, &nvs) == ESP_OK) {
|
||||
nvs_set_str(nvs, nvs_key, base_url);
|
||||
nvs_commit(nvs);
|
||||
nvs_close(nvs);
|
||||
ESP_LOGI(TAG, "Base URL saved for provider: %s", provider_name);
|
||||
}
|
||||
|
||||
/* If this is the current provider, update in-memory Base URL */
|
||||
if (strcmp(provider_name, s_current_provider->name) == 0) {
|
||||
strncpy(s_base_url, base_url, sizeof(s_base_url) - 1);
|
||||
s_base_url[sizeof(s_base_url) - 1] = '\0';
|
||||
}
|
||||
}
|
||||
|
||||
/* Get API key for a provider */
|
||||
const char *llm_provider_get_api_key(const char *provider_name) {
|
||||
if (!provider_name) return NULL;
|
||||
|
||||
/* If this is the current provider, return in-memory key */
|
||||
if (strcmp(provider_name, s_current_provider->name) == 0) {
|
||||
return s_api_key;
|
||||
}
|
||||
|
||||
/* Otherwise, load from NVS */
|
||||
static char key_buffer[LLM_API_KEY_MAX_LEN] = {0};
|
||||
const char *nvs_key = get_provider_api_key_nvs_key(provider_name);
|
||||
if (!nvs_key) return NULL;
|
||||
|
||||
nvs_handle_t nvs;
|
||||
if (nvs_open(MIMI_NVS_LLM, NVS_READONLY, &nvs) == ESP_OK) {
|
||||
size_t len = sizeof(key_buffer);
|
||||
if (nvs_get_str(nvs, nvs_key, key_buffer, &len) == ESP_OK && key_buffer[0]) {
|
||||
nvs_close(nvs);
|
||||
return key_buffer;
|
||||
}
|
||||
nvs_close(nvs);
|
||||
}
|
||||
return NULL;
|
||||
}
|
||||
|
||||
/* Get Base URL for a provider */
|
||||
const char *llm_provider_get_base_url(const char *provider_name) {
|
||||
if (!provider_name) return NULL;
|
||||
|
||||
/* If this is the current provider, return in-memory Base URL */
|
||||
if (strcmp(provider_name, s_current_provider->name) == 0) {
|
||||
return s_base_url[0] ? s_base_url : NULL;
|
||||
}
|
||||
|
||||
/* Otherwise, load from NVS */
|
||||
static char url_buffer[256] = {0};
|
||||
const char *nvs_key = get_provider_base_url_nvs_key(provider_name);
|
||||
if (!nvs_key) return NULL;
|
||||
|
||||
nvs_handle_t nvs;
|
||||
if (nvs_open(MIMI_NVS_LLM, NVS_READONLY, &nvs) == ESP_OK) {
|
||||
size_t len = sizeof(url_buffer);
|
||||
if (nvs_get_str(nvs, nvs_key, url_buffer, &len) == ESP_OK && url_buffer[0]) {
|
||||
nvs_close(nvs);
|
||||
return url_buffer;
|
||||
}
|
||||
nvs_close(nvs);
|
||||
}
|
||||
return NULL;
|
||||
}
|
||||
|
||||
/* Initialize provider system (load from NVS) */
|
||||
void llm_provider_init(void) {
|
||||
/* Load API key for current provider */
|
||||
const char *api_key = llm_provider_get_api_key(s_current_provider->name);
|
||||
if (api_key) {
|
||||
strncpy(s_api_key, api_key, sizeof(s_api_key) - 1);
|
||||
s_api_key[sizeof(s_api_key) - 1] = '\0';
|
||||
} else {
|
||||
s_api_key[0] = '\0';
|
||||
}
|
||||
|
||||
/* Load Base URL for current provider */
|
||||
const char *base_url = llm_provider_get_base_url(s_current_provider->name);
|
||||
if (base_url) {
|
||||
strncpy(s_base_url, base_url, sizeof(s_base_url) - 1);
|
||||
s_base_url[sizeof(s_base_url) - 1] = '\0';
|
||||
} else {
|
||||
s_base_url[0] = '\0';
|
||||
}
|
||||
|
||||
ESP_LOGI(TAG, "Provider initialized: %s (API key: %s, Base URL: %s)",
|
||||
s_current_provider->name,
|
||||
s_api_key[0] ? "set" : "not set",
|
||||
s_base_url[0] ? s_base_url : "default");
|
||||
}
|
||||
|
||||
/* Save provider configuration to NVS */
|
||||
void llm_provider_save_config(const char *provider_name) {
|
||||
/* This function is intentionally left empty -
|
||||
individual set functions already save to NVS */
|
||||
}
|
||||
|
||||
/* Common authentication header setup for OpenAI-compatible providers */
|
||||
void llm_provider_set_auth_headers(esp_http_client_handle_t client, const char *api_key) {
|
||||
if (!client || !api_key) return;
|
||||
|
||||
/* All OpenAI-compatible providers use Bearer token authentication */
|
||||
char auth[LLM_API_KEY_MAX_LEN + 16];
|
||||
snprintf(auth, sizeof(auth), "Bearer %s", api_key);
|
||||
esp_http_client_set_header(client, "Authorization", auth);
|
||||
}
|
||||
68
main/llm/llm_provider.h
Normal file
68
main/llm/llm_provider.h
Normal file
@@ -0,0 +1,68 @@
|
||||
#pragma once
|
||||
|
||||
#include <stdbool.h>
|
||||
#include <stddef.h>
|
||||
#include "esp_err.h"
|
||||
|
||||
#ifdef __cplusplus
|
||||
extern "C" {
|
||||
#endif
|
||||
|
||||
/* Forward declaration for esp_http_client_handle_t */
|
||||
typedef struct esp_http_client* esp_http_client_handle_t;
|
||||
|
||||
/* Provider configuration structure */
|
||||
typedef struct {
|
||||
const char *name; /* Provider name, e.g., "anthropic", "openai", "siliconflow", "volcengine" */
|
||||
const char *default_api_url; /* Default API URL */
|
||||
const char *default_host; /* Default hostname */
|
||||
const char *default_path; /* Default API path */
|
||||
bool is_openai_compatible; /* Whether this provider uses OpenAI-compatible API */
|
||||
} llm_provider_config_t;
|
||||
|
||||
/* Provider registry - all supported providers */
|
||||
extern const llm_provider_config_t llm_providers[];
|
||||
extern const int llm_provider_count;
|
||||
|
||||
/* Find provider configuration by name */
|
||||
const llm_provider_config_t *llm_provider_find(const char *name);
|
||||
|
||||
/* Get current provider configuration */
|
||||
const llm_provider_config_t *llm_provider_current(void);
|
||||
|
||||
/* Set current provider by name */
|
||||
void llm_provider_set_current(const char *name);
|
||||
|
||||
/* Get current provider name */
|
||||
const char *llm_provider_current_name(void);
|
||||
|
||||
/* Check if current provider is OpenAI-compatible */
|
||||
bool llm_provider_is_openai_compatible(void);
|
||||
|
||||
/* Get API URL for current provider (with dynamic Base URL support) */
|
||||
const char *llm_provider_api_url(void);
|
||||
|
||||
/* Get hostname for current provider */
|
||||
const char *llm_provider_host(void);
|
||||
|
||||
/* Get API path for current provider */
|
||||
const char *llm_provider_path(void);
|
||||
|
||||
/* Provider-specific API key and Base URL management */
|
||||
void llm_provider_set_api_key(const char *provider_name, const char *api_key);
|
||||
void llm_provider_set_base_url(const char *provider_name, const char *base_url);
|
||||
const char *llm_provider_get_api_key(const char *provider_name);
|
||||
const char *llm_provider_get_base_url(const char *provider_name);
|
||||
|
||||
/* Initialize provider system (load from NVS) */
|
||||
void llm_provider_init(void);
|
||||
|
||||
/* Save provider configuration to NVS */
|
||||
void llm_provider_save_config(const char *provider_name);
|
||||
|
||||
/* Common authentication header setup for OpenAI-compatible providers */
|
||||
void llm_provider_set_auth_headers(esp_http_client_handle_t client, const char *api_key);
|
||||
|
||||
#ifdef __cplusplus
|
||||
}
|
||||
#endif
|
||||
@@ -1,9 +1,11 @@
|
||||
#include "llm_proxy.h"
|
||||
#include "llm_provider.h"
|
||||
#include "mimi_config.h"
|
||||
#include "proxy/http_proxy.h"
|
||||
|
||||
#include <string.h>
|
||||
#include <stdlib.h>
|
||||
#include <stdbool.h>
|
||||
#include "esp_log.h"
|
||||
#include "esp_http_client.h"
|
||||
#include "esp_crt_bundle.h"
|
||||
@@ -184,28 +186,31 @@ static esp_err_t http_event_handler(esp_http_client_event_t *evt)
|
||||
|
||||
static bool provider_is_openai(void)
|
||||
{
|
||||
return strcmp(s_provider, "openai") == 0;
|
||||
return llm_provider_is_openai_compatible();
|
||||
}
|
||||
|
||||
static const char *llm_api_url(void)
|
||||
{
|
||||
return provider_is_openai() ? MIMI_OPENAI_API_URL : MIMI_LLM_API_URL;
|
||||
return llm_provider_api_url();
|
||||
}
|
||||
|
||||
static const char *llm_api_host(void)
|
||||
{
|
||||
return provider_is_openai() ? "api.openai.com" : "api.anthropic.com";
|
||||
return llm_provider_host();
|
||||
}
|
||||
|
||||
static const char *llm_api_path(void)
|
||||
{
|
||||
return provider_is_openai() ? "/v1/chat/completions" : "/v1/messages";
|
||||
return llm_provider_path();
|
||||
}
|
||||
|
||||
/* ── Init ─────────────────────────────────────────────────────── */
|
||||
|
||||
esp_err_t llm_proxy_init(void)
|
||||
{
|
||||
/* Initialize provider system */
|
||||
llm_provider_init();
|
||||
|
||||
/* Start with build-time defaults */
|
||||
if (MIMI_SECRET_API_KEY[0] != '\0') {
|
||||
safe_copy(s_api_key, sizeof(s_api_key), MIMI_SECRET_API_KEY);
|
||||
@@ -215,6 +220,8 @@ esp_err_t llm_proxy_init(void)
|
||||
}
|
||||
if (MIMI_SECRET_MODEL_PROVIDER[0] != '\0') {
|
||||
safe_copy(s_provider, sizeof(s_provider), MIMI_SECRET_MODEL_PROVIDER);
|
||||
/* Set current provider based on build-time default */
|
||||
llm_provider_set_current(s_provider);
|
||||
}
|
||||
|
||||
/* NVS overrides take highest priority (set via CLI) */
|
||||
@@ -234,10 +241,31 @@ esp_err_t llm_proxy_init(void)
|
||||
len = sizeof(provider_tmp);
|
||||
if (nvs_get_str(nvs, MIMI_NVS_KEY_PROVIDER, provider_tmp, &len) == ESP_OK && provider_tmp[0]) {
|
||||
safe_copy(s_provider, sizeof(s_provider), provider_tmp);
|
||||
/* Set current provider based on NVS override */
|
||||
llm_provider_set_current(s_provider);
|
||||
}
|
||||
nvs_close(nvs);
|
||||
}
|
||||
|
||||
/* Load provider-specific API key from NVS */
|
||||
const char *provider_api_key = llm_provider_get_api_key(s_provider);
|
||||
if (provider_api_key && provider_api_key[0]) {
|
||||
safe_copy(s_api_key, sizeof(s_api_key), provider_api_key);
|
||||
}
|
||||
|
||||
/* Fall back to build-time provider-specific API key if NVS key is empty */
|
||||
if (s_api_key[0] == '\0') {
|
||||
if (strcmp(s_provider, "siliconflow") == 0 && MIMI_SECRET_SILICONFLOW_API_KEY[0] != '\0') {
|
||||
safe_copy(s_api_key, sizeof(s_api_key), MIMI_SECRET_SILICONFLOW_API_KEY);
|
||||
} else if (strcmp(s_provider, "volcengine") == 0 && MIMI_SECRET_VOLCENGINE_API_KEY[0] != '\0') {
|
||||
safe_copy(s_api_key, sizeof(s_api_key), MIMI_SECRET_VOLCENGINE_API_KEY);
|
||||
} else if (strcmp(s_provider, "openai") == 0 && MIMI_SECRET_OPENAI_API_KEY[0] != '\0') {
|
||||
safe_copy(s_api_key, sizeof(s_api_key), MIMI_SECRET_OPENAI_API_KEY);
|
||||
} else if (strcmp(s_provider, "anthropic") == 0 && MIMI_SECRET_ANTHROPIC_API_KEY[0] != '\0') {
|
||||
safe_copy(s_api_key, sizeof(s_api_key), MIMI_SECRET_ANTHROPIC_API_KEY);
|
||||
}
|
||||
}
|
||||
|
||||
if (s_api_key[0]) {
|
||||
ESP_LOGI(TAG, "LLM proxy initialized (provider: %s, model: %s)", s_provider, s_model);
|
||||
} else {
|
||||
@@ -265,13 +293,15 @@ static esp_err_t llm_http_direct(const char *post_data, resp_buf_t *rb, int *out
|
||||
|
||||
esp_http_client_set_method(client, HTTP_METHOD_POST);
|
||||
esp_http_client_set_header(client, "Content-Type", "application/json");
|
||||
if (provider_is_openai()) {
|
||||
|
||||
/* Use provider-specific authentication */
|
||||
if (llm_provider_is_openai_compatible()) {
|
||||
/* OpenAI-compatible providers use Bearer token authentication */
|
||||
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);
|
||||
llm_provider_set_auth_headers(client, s_api_key);
|
||||
}
|
||||
} else {
|
||||
/* Anthropic uses x-api-key authentication */
|
||||
esp_http_client_set_header(client, "x-api-key", s_api_key);
|
||||
esp_http_client_set_header(client, "anthropic-version", MIMI_LLM_API_VERSION);
|
||||
}
|
||||
@@ -293,7 +323,9 @@ static esp_err_t llm_http_via_proxy(const char *post_data, resp_buf_t *rb, int *
|
||||
int body_len = strlen(post_data);
|
||||
char header[1024];
|
||||
int hlen = 0;
|
||||
if (provider_is_openai()) {
|
||||
|
||||
if (llm_provider_is_openai_compatible()) {
|
||||
/* OpenAI-compatible providers use Bearer token authentication */
|
||||
hlen = snprintf(header, sizeof(header),
|
||||
"POST %s HTTP/1.1\r\n"
|
||||
"Host: %s\r\n"
|
||||
@@ -303,6 +335,7 @@ static esp_err_t llm_http_via_proxy(const char *post_data, resp_buf_t *rb, int *
|
||||
"Connection: close\r\n\r\n",
|
||||
llm_api_path(), llm_api_host(), s_api_key, body_len);
|
||||
} else {
|
||||
/* Anthropic uses x-api-key authentication */
|
||||
hlen = snprintf(header, sizeof(header),
|
||||
"POST %s HTTP/1.1\r\n"
|
||||
"Host: %s\r\n"
|
||||
@@ -559,13 +592,13 @@ esp_err_t llm_chat_tools(const char *system_prompt,
|
||||
/* Build request body (non-streaming) */
|
||||
cJSON *body = cJSON_CreateObject();
|
||||
cJSON_AddStringToObject(body, "model", s_model);
|
||||
if (provider_is_openai()) {
|
||||
if (llm_provider_is_openai_compatible()) {
|
||||
cJSON_AddNumberToObject(body, "max_completion_tokens", MIMI_LLM_MAX_TOKENS);
|
||||
} else {
|
||||
cJSON_AddNumberToObject(body, "max_tokens", MIMI_LLM_MAX_TOKENS);
|
||||
}
|
||||
|
||||
if (provider_is_openai()) {
|
||||
if (llm_provider_is_openai_compatible()) {
|
||||
cJSON *openai_msgs = convert_messages_openai(system_prompt, messages);
|
||||
cJSON_AddItemToObject(body, "messages", openai_msgs);
|
||||
|
||||
@@ -635,7 +668,7 @@ esp_err_t llm_chat_tools(const char *system_prompt,
|
||||
return ESP_FAIL;
|
||||
}
|
||||
|
||||
if (provider_is_openai()) {
|
||||
if (llm_provider_is_openai_compatible()) {
|
||||
cJSON *choices = cJSON_GetObjectItem(root, "choices");
|
||||
cJSON *choice0 = choices && cJSON_IsArray(choices) ? cJSON_GetArrayItem(choices, 0) : NULL;
|
||||
if (choice0) {
|
||||
@@ -780,7 +813,11 @@ esp_err_t llm_set_api_key(const char *api_key)
|
||||
nvs_close(nvs);
|
||||
|
||||
safe_copy(s_api_key, sizeof(s_api_key), api_key);
|
||||
ESP_LOGI(TAG, "API key saved");
|
||||
|
||||
/* Also save to provider-specific NVS key */
|
||||
llm_provider_set_api_key(s_provider, api_key);
|
||||
|
||||
ESP_LOGI(TAG, "API key saved for provider: %s", s_provider);
|
||||
return ESP_OK;
|
||||
}
|
||||
|
||||
@@ -806,6 +843,43 @@ esp_err_t llm_set_provider(const char *provider)
|
||||
nvs_close(nvs);
|
||||
|
||||
safe_copy(s_provider, sizeof(s_provider), provider);
|
||||
|
||||
/* Update current provider in the provider system */
|
||||
llm_provider_set_current(provider);
|
||||
|
||||
/* Load provider-specific API key if available */
|
||||
const char *provider_api_key = llm_provider_get_api_key(provider);
|
||||
if (provider_api_key && provider_api_key[0]) {
|
||||
safe_copy(s_api_key, sizeof(s_api_key), provider_api_key);
|
||||
} else {
|
||||
s_api_key[0] = '\0';
|
||||
}
|
||||
|
||||
ESP_LOGI(TAG, "Provider set to: %s", s_provider);
|
||||
return ESP_OK;
|
||||
}
|
||||
|
||||
esp_err_t llm_set_base_url(const char *provider, const char *base_url)
|
||||
{
|
||||
if (!provider || !base_url) return ESP_ERR_INVALID_ARG;
|
||||
|
||||
/* Save to provider-specific NVS key */
|
||||
llm_provider_set_base_url(provider, base_url);
|
||||
|
||||
/* If this is the current provider, update in-memory Base URL */
|
||||
if (strcmp(provider, s_provider) == 0) {
|
||||
/* Reload provider configuration to pick up new Base URL */
|
||||
llm_provider_init();
|
||||
}
|
||||
|
||||
ESP_LOGI(TAG, "Base URL set for provider: %s", provider);
|
||||
return ESP_OK;
|
||||
}
|
||||
|
||||
const char *llm_get_base_url(const char *provider)
|
||||
{
|
||||
if (!provider) return NULL;
|
||||
|
||||
/* Get Base URL from provider system */
|
||||
return llm_provider_get_base_url(provider);
|
||||
}
|
||||
|
||||
@@ -27,6 +27,16 @@ esp_err_t llm_set_provider(const char *provider);
|
||||
*/
|
||||
esp_err_t llm_set_model(const char *model);
|
||||
|
||||
/**
|
||||
* Save the Base URL for a provider to NVS.
|
||||
*/
|
||||
esp_err_t llm_set_base_url(const char *provider, const char *base_url);
|
||||
|
||||
/**
|
||||
* Get the Base URL for a provider from NVS.
|
||||
*/
|
||||
const char *llm_get_base_url(const char *provider);
|
||||
|
||||
/* ── Tool Use Support ──────────────────────────────────────────── */
|
||||
|
||||
typedef struct {
|
||||
|
||||
@@ -24,7 +24,7 @@ esp_err_t session_mgr_init(void)
|
||||
|
||||
esp_err_t session_append(const char *chat_id, const char *role, const char *content)
|
||||
{
|
||||
char path[64];
|
||||
char path[128];
|
||||
session_path(chat_id, path, sizeof(path));
|
||||
|
||||
FILE *f = fopen(path, "a");
|
||||
@@ -52,7 +52,7 @@ esp_err_t session_append(const char *chat_id, const char *role, const char *cont
|
||||
|
||||
esp_err_t session_get_history_json(const char *chat_id, char *buf, size_t size, int max_msgs)
|
||||
{
|
||||
char path[64];
|
||||
char path[128];
|
||||
session_path(chat_id, path, sizeof(path));
|
||||
|
||||
FILE *f = fopen(path, "r");
|
||||
@@ -127,7 +127,7 @@ esp_err_t session_get_history_json(const char *chat_id, char *buf, size_t size,
|
||||
|
||||
esp_err_t session_clear(const char *chat_id)
|
||||
{
|
||||
char path[64];
|
||||
char path[128];
|
||||
session_path(chat_id, path, sizeof(path));
|
||||
|
||||
if (remove(path) == 0) {
|
||||
|
||||
26
main/mimi.c
26
main/mimi.c
@@ -1,5 +1,6 @@
|
||||
#include <stdio.h>
|
||||
#include <string.h>
|
||||
#include <stdbool.h>
|
||||
#include "freertos/FreeRTOS.h"
|
||||
#include "freertos/task.h"
|
||||
#include "esp_log.h"
|
||||
@@ -25,6 +26,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,13 +143,32 @@ 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());
|
||||
} else {
|
||||
ESP_LOGW(TAG, "WiFi connection timeout");
|
||||
}
|
||||
} else {
|
||||
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",
|
||||
@@ -164,11 +185,6 @@ void app_main(void)
|
||||
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");
|
||||
}
|
||||
} else {
|
||||
ESP_LOGW(TAG, "No WiFi credentials. Set MIMI_SECRET_WIFI_SSID in mimi_secrets.h");
|
||||
}
|
||||
|
||||
ESP_LOGI(TAG, "MimiClaw ready. Type 'help' for CLI commands.");
|
||||
|
||||
@@ -47,6 +47,26 @@
|
||||
#define MIMI_SECRET_TAVILY_KEY ""
|
||||
#endif
|
||||
|
||||
/* Provider-specific API keys (fallback when generic MIMI_SECRET_API_KEY is empty) */
|
||||
#ifndef MIMI_SECRET_ANTHROPIC_API_KEY
|
||||
#define MIMI_SECRET_ANTHROPIC_API_KEY ""
|
||||
#endif
|
||||
#ifndef MIMI_SECRET_OPENAI_API_KEY
|
||||
#define MIMI_SECRET_OPENAI_API_KEY ""
|
||||
#endif
|
||||
#ifndef MIMI_SECRET_SILICONFLOW_API_KEY
|
||||
#define MIMI_SECRET_SILICONFLOW_API_KEY ""
|
||||
#endif
|
||||
#ifndef MIMI_SECRET_SILICONFLOW_BASE_URL
|
||||
#define MIMI_SECRET_SILICONFLOW_BASE_URL "https://api.siliconflow.cn/v1"
|
||||
#endif
|
||||
#ifndef MIMI_SECRET_VOLCENGINE_API_KEY
|
||||
#define MIMI_SECRET_VOLCENGINE_API_KEY ""
|
||||
#endif
|
||||
#ifndef MIMI_SECRET_VOLCENGINE_BASE_URL
|
||||
#define MIMI_SECRET_VOLCENGINE_BASE_URL "https://ark.cn-beijing.volces.com/api/v3"
|
||||
#endif
|
||||
|
||||
/* WiFi */
|
||||
#define MIMI_WIFI_MAX_RETRY 10
|
||||
#define MIMI_WIFI_RETRY_BASE_MS 1000
|
||||
@@ -80,7 +100,7 @@
|
||||
#define MIMI_AGENT_SEND_WORKING_STATUS 1
|
||||
|
||||
/* Timezone (POSIX TZ format) */
|
||||
#define MIMI_TIMEZONE "PST8PDT,M3.2.0,M11.1.0"
|
||||
#define MIMI_TIMEZONE "CST-8"
|
||||
|
||||
/* LLM */
|
||||
#define MIMI_LLM_DEFAULT_MODEL "claude-opus-4-5"
|
||||
@@ -88,6 +108,8 @@
|
||||
#define MIMI_LLM_MAX_TOKENS 4096
|
||||
#define MIMI_LLM_API_URL "https://api.anthropic.com/v1/messages"
|
||||
#define MIMI_OPENAI_API_URL "https://api.openai.com/v1/chat/completions"
|
||||
#define MIMI_SILICONFLOW_API_URL "https://api.siliconflow.cn/v1/chat/completions"
|
||||
#define MIMI_VOLCENGINE_API_URL "https://ark.cn-beijing.volces.com/api/v3/chat/completions"
|
||||
#define MIMI_LLM_API_VERSION "2023-06-01"
|
||||
#define MIMI_LLM_STREAM_BUF_SIZE (32 * 1024)
|
||||
#define MIMI_LLM_LOG_VERBOSE_PAYLOAD 0
|
||||
@@ -117,6 +139,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 +174,24 @@
|
||||
#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"
|
||||
|
||||
/* Provider-specific NVS Keys */
|
||||
#define MIMI_NVS_KEY_ANTHROPIC_API_KEY "anthropic_api_key"
|
||||
#define MIMI_NVS_KEY_ANTHROPIC_BASE_URL "anthropic_base_url"
|
||||
#define MIMI_NVS_KEY_OPENAI_API_KEY "openai_api_key"
|
||||
#define MIMI_NVS_KEY_OPENAI_BASE_URL "openai_base_url"
|
||||
#define MIMI_NVS_KEY_SILICONFLOW_API_KEY "siliconflow_api_key"
|
||||
#define MIMI_NVS_KEY_SILICONFLOW_BASE_URL "siliconflow_base_url"
|
||||
#define MIMI_NVS_KEY_VOLCENGINE_API_KEY "volcengine_api_key"
|
||||
#define MIMI_NVS_KEY_VOLCENGINE_BASE_URL "volcengine_base_url"
|
||||
|
||||
/* System NVS Keys */
|
||||
#define MIMI_NVS_KEY_TIMEZONE "timezone"
|
||||
|
||||
/* 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
|
||||
|
||||
@@ -21,11 +21,35 @@
|
||||
#define MIMI_SECRET_FEISHU_APP_ID ""
|
||||
#define MIMI_SECRET_FEISHU_APP_SECRET ""
|
||||
|
||||
/* Anthropic API */
|
||||
/* LLM Configuration
|
||||
*
|
||||
* Method 1: Generic API key (works for any provider)
|
||||
* Set MIMI_SECRET_API_KEY + MIMI_SECRET_MODEL_PROVIDER + MIMI_SECRET_MODEL
|
||||
*
|
||||
* Method 2: Provider-specific API keys (recommended for multi-provider setups)
|
||||
* Set MIMI_SECRET_<PROVIDER>_API_KEY for each provider you want to use
|
||||
* Switch providers via onboard portal or CLI: set_model_provider <provider>
|
||||
*
|
||||
* Priority: NVS (runtime config) > provider-specific key > generic key
|
||||
*/
|
||||
#define MIMI_SECRET_API_KEY ""
|
||||
#define MIMI_SECRET_MODEL ""
|
||||
#define MIMI_SECRET_MODEL "claude-opus-4-5"
|
||||
#define MIMI_SECRET_MODEL_PROVIDER "anthropic"
|
||||
|
||||
/* Anthropic (Claude) */
|
||||
#define MIMI_SECRET_ANTHROPIC_API_KEY ""
|
||||
|
||||
/* OpenAI (GPT) */
|
||||
#define MIMI_SECRET_OPENAI_API_KEY ""
|
||||
|
||||
/* SiliconFlow (硅基流动, OpenAI-compatible) */
|
||||
#define MIMI_SECRET_SILICONFLOW_API_KEY ""
|
||||
#define MIMI_SECRET_SILICONFLOW_BASE_URL "https://api.siliconflow.cn/v1"
|
||||
|
||||
/* Volcengine (火山引擎/豆包, OpenAI-compatible) */
|
||||
#define MIMI_SECRET_VOLCENGINE_API_KEY ""
|
||||
#define MIMI_SECRET_VOLCENGINE_BASE_URL "https://ark.cn-beijing.volces.com/api/v3"
|
||||
|
||||
/* HTTP Proxy (leave empty or set both) */
|
||||
#define MIMI_SECRET_PROXY_HOST ""
|
||||
#define MIMI_SECRET_PROXY_PORT ""
|
||||
|
||||
158
main/onboard/onboard_html.h
Normal file
158
main/onboard/onboard_html.h
Normal file
@@ -0,0 +1,158 @@
|
||||
#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' onchange='onProviderChange()'>"
|
||||
"<option value='anthropic'>Anthropic</option>"
|
||||
"<option value='openai'>OpenAI</option>"
|
||||
"<option value='siliconflow'>SiliconFlow (硅基流动)</option>"
|
||||
"<option value='volcengine'>Volcengine (火山引擎)</option>"
|
||||
"</select>"
|
||||
"<label>Base URL</label>"
|
||||
"<input id='base_url' placeholder='https://api.example.com/v1'>"
|
||||
"</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 & 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 onProviderChange(){"
|
||||
"var p=document.getElementById('provider').value;"
|
||||
"var u=document.getElementById('base_url');"
|
||||
"if(p==='siliconflow'){u.value='https://api.siliconflow.cn/v1'}"
|
||||
"else if(p==='volcengine'){u.value='https://ark.cn-beijing.volces.com/api/v3'}"
|
||||
"else{u.value=''}}"
|
||||
|
||||
"function save(){"
|
||||
"var fields=['ssid','password','api_key','model','provider','base_url','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>";
|
||||
590
main/onboard/wifi_onboard.c
Normal file
590
main/onboard/wifi_onboard.c
Normal file
@@ -0,0 +1,590 @@
|
||||
#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);
|
||||
|
||||
/* Provider-specific Base URL (load from current provider's NVS key) */
|
||||
{
|
||||
char base_url[256] = {0};
|
||||
bool found = false;
|
||||
char provider[32] = {0};
|
||||
nvs_handle_t nvs;
|
||||
if (nvs_open(MIMI_NVS_LLM, NVS_READONLY, &nvs) == ESP_OK) {
|
||||
size_t len = sizeof(provider);
|
||||
if (nvs_get_str(nvs, MIMI_NVS_KEY_PROVIDER, provider, &len) != ESP_OK || !provider[0]) {
|
||||
/* Fall back to build-time provider if NVS doesn't have one */
|
||||
strlcpy(provider, MIMI_SECRET_MODEL_PROVIDER, sizeof(provider));
|
||||
}
|
||||
/* Try to load provider-specific base URL from NVS */
|
||||
const char *url_key = NULL;
|
||||
if (strcmp(provider, "anthropic") == 0) url_key = MIMI_NVS_KEY_ANTHROPIC_BASE_URL;
|
||||
else if (strcmp(provider, "openai") == 0) url_key = MIMI_NVS_KEY_OPENAI_BASE_URL;
|
||||
else if (strcmp(provider, "siliconflow") == 0) url_key = MIMI_NVS_KEY_SILICONFLOW_BASE_URL;
|
||||
else if (strcmp(provider, "volcengine") == 0) url_key = MIMI_NVS_KEY_VOLCENGINE_BASE_URL;
|
||||
if (url_key) {
|
||||
len = sizeof(base_url);
|
||||
if (nvs_get_str(nvs, url_key, base_url, &len) == ESP_OK && base_url[0]) {
|
||||
found = true;
|
||||
}
|
||||
}
|
||||
nvs_close(nvs);
|
||||
}
|
||||
if (!found) {
|
||||
/* Fall back to build-time defaults */
|
||||
if (strcmp(provider, "siliconflow") == 0) strlcpy(base_url, MIMI_SECRET_SILICONFLOW_BASE_URL, sizeof(base_url));
|
||||
else if (strcmp(provider, "volcengine") == 0) strlcpy(base_url, MIMI_SECRET_VOLCENGINE_BASE_URL, sizeof(base_url));
|
||||
}
|
||||
cJSON_AddStringToObject(root, "base_url", base_url);
|
||||
}
|
||||
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);
|
||||
|
||||
/* Save API key and base URL to provider-specific NVS keys */
|
||||
{
|
||||
cJSON *provider_item = cJSON_GetObjectItem(root, "provider");
|
||||
cJSON *api_key_item = cJSON_GetObjectItem(root, "api_key");
|
||||
cJSON *base_url_item = cJSON_GetObjectItem(root, "base_url");
|
||||
if (provider_item && cJSON_IsString(provider_item) && provider_item->valuestring[0]) {
|
||||
const char *provider = provider_item->valuestring;
|
||||
const char *api_key_nvs = NULL;
|
||||
const char *base_url_nvs = NULL;
|
||||
if (strcmp(provider, "anthropic") == 0) {
|
||||
api_key_nvs = MIMI_NVS_KEY_ANTHROPIC_API_KEY;
|
||||
base_url_nvs = MIMI_NVS_KEY_ANTHROPIC_BASE_URL;
|
||||
} else if (strcmp(provider, "openai") == 0) {
|
||||
api_key_nvs = MIMI_NVS_KEY_OPENAI_API_KEY;
|
||||
base_url_nvs = MIMI_NVS_KEY_OPENAI_BASE_URL;
|
||||
} else if (strcmp(provider, "siliconflow") == 0) {
|
||||
api_key_nvs = MIMI_NVS_KEY_SILICONFLOW_API_KEY;
|
||||
base_url_nvs = MIMI_NVS_KEY_SILICONFLOW_BASE_URL;
|
||||
} else if (strcmp(provider, "volcengine") == 0) {
|
||||
api_key_nvs = MIMI_NVS_KEY_VOLCENGINE_API_KEY;
|
||||
base_url_nvs = MIMI_NVS_KEY_VOLCENGINE_BASE_URL;
|
||||
}
|
||||
if (api_key_nvs && api_key_item && cJSON_IsString(api_key_item)) {
|
||||
nvs_sync_field(root, "api_key", MIMI_NVS_LLM, api_key_nvs);
|
||||
}
|
||||
if (base_url_nvs && base_url_item && cJSON_IsString(base_url_item)) {
|
||||
nvs_sync_field(root, "base_url", MIMI_NVS_LLM, base_url_nvs);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/* 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 */
|
||||
}
|
||||
15
main/onboard/wifi_onboard.h
Normal file
15
main/onboard/wifi_onboard.h
Normal 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);
|
||||
@@ -5,6 +5,7 @@
|
||||
#include "esp_http_client.h"
|
||||
#include "esp_crt_bundle.h"
|
||||
#include "esp_https_ota.h"
|
||||
#include "esp_system.h"
|
||||
|
||||
static const char *TAG = "ota";
|
||||
|
||||
|
||||
@@ -3,8 +3,10 @@
|
||||
|
||||
#include <string.h>
|
||||
#include <stdlib.h>
|
||||
#include <stdbool.h>
|
||||
#include <errno.h>
|
||||
#include <sys/socket.h>
|
||||
#include <sys/time.h>
|
||||
#include <netdb.h>
|
||||
#include <unistd.h>
|
||||
|
||||
@@ -245,7 +247,7 @@ static int open_socks5_tunnel(const char *host, int port, int timeout_ms)
|
||||
free(req);
|
||||
|
||||
/* Receive connect response */
|
||||
unsigned char resp[256];
|
||||
unsigned char resp[512];
|
||||
int resp_len = recv(sock, resp, sizeof(resp), 0);
|
||||
if (resp_len < 10) {
|
||||
ESP_LOGE(TAG, "No response from SOCKS5 proxy"); close(sock); return -1;
|
||||
|
||||
124
main/tools/gpio_policy.c
Normal file
124
main/tools/gpio_policy.c
Normal file
@@ -0,0 +1,124 @@
|
||||
#include "tools/gpio_policy.h"
|
||||
|
||||
#include "driver/gpio.h"
|
||||
|
||||
#include <stdlib.h>
|
||||
#include <stdio.h>
|
||||
#include <stdbool.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
22
main/tools/gpio_policy.h
Normal 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);
|
||||
@@ -4,6 +4,7 @@
|
||||
|
||||
#include <string.h>
|
||||
#include <stdlib.h>
|
||||
#include <stdbool.h>
|
||||
#include <time.h>
|
||||
#include <sys/time.h>
|
||||
#include "esp_log.h"
|
||||
|
||||
194
main/tools/tool_gpio.c
Normal file
194
main/tools/tool_gpio.c
Normal 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
27
main/tools/tool_gpio.h
Normal 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);
|
||||
@@ -2,16 +2,19 @@
|
||||
#include "mimi_config.h"
|
||||
#include "tools/tool_web_search.h"
|
||||
#include "tools/tool_get_time.h"
|
||||
#include "tools/tool_set_timezone.h"
|
||||
#include "tools/tool_files.h"
|
||||
#include "tools/tool_cron.h"
|
||||
#include "tools/tool_gpio.h"
|
||||
|
||||
#include <string.h>
|
||||
#include <stdlib.h>
|
||||
#include "esp_log.h"
|
||||
#include "cJSON.h"
|
||||
|
||||
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;
|
||||
@@ -81,6 +84,18 @@ esp_err_t tool_registry_init(void)
|
||||
};
|
||||
register_tool(>);
|
||||
|
||||
/* Register set_timezone */
|
||||
mimi_tool_t stz = {
|
||||
.name = "set_timezone",
|
||||
.description = "Set the system timezone. Accepts POSIX format (e.g. CST-8, EST5EDT,M3.2.0,M11.1.0) or city name (e.g. Asia/Shanghai, America/New_York).",
|
||||
.input_schema_json =
|
||||
"{\"type\":\"object\","
|
||||
"\"properties\":{\"timezone\":{\"type\":\"string\",\"description\":\"Timezone in POSIX format or city name (e.g. CST-8, Asia/Shanghai)\"}},"
|
||||
"\"required\":[\"timezone\"]}",
|
||||
.execute = tool_set_timezone_execute,
|
||||
};
|
||||
register_tool(&stz);
|
||||
|
||||
/* Register read_file */
|
||||
mimi_tool_t rf = {
|
||||
.name = "read_file",
|
||||
@@ -176,6 +191,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");
|
||||
|
||||
145
main/tools/tool_set_timezone.c
Normal file
145
main/tools/tool_set_timezone.c
Normal file
@@ -0,0 +1,145 @@
|
||||
#include "tool_set_timezone.h"
|
||||
#include "mimi_config.h"
|
||||
|
||||
#include <string.h>
|
||||
#include <stdlib.h>
|
||||
#include <stdbool.h>
|
||||
#include <time.h>
|
||||
#include "esp_log.h"
|
||||
#include "nvs.h"
|
||||
#include "cJSON.h"
|
||||
|
||||
static const char *TAG = "tool_timezone";
|
||||
|
||||
/* Common timezone mappings for user-friendly names */
|
||||
typedef struct {
|
||||
const char *name;
|
||||
const char *posix_tz;
|
||||
} tz_mapping_t;
|
||||
|
||||
static const tz_mapping_t tz_mappings[] = {
|
||||
{ "Asia/Shanghai", "CST-8" },
|
||||
{ "Asia/Beijing", "CST-8" },
|
||||
{ "Asia/Hong_Kong", "HKT-8" },
|
||||
{ "Asia/Tokyo", "JST-9" },
|
||||
{ "Asia/Seoul", "KST-9" },
|
||||
{ "Asia/Singapore", "SGT-8" },
|
||||
{ "Asia/Kolkata", "IST-5:30" },
|
||||
{ "Asia/Dubai", "GST-4" },
|
||||
{ "Europe/London", "GMT0BST,M3.5.0/1,M10.5.0" },
|
||||
{ "Europe/Paris", "CET-1CEST,M3.5.0,M10.5.0/3" },
|
||||
{ "Europe/Berlin", "CET-1CEST,M3.5.0,M10.5.0/3" },
|
||||
{ "America/New_York", "EST5EDT,M3.2.0,M11.1.0" },
|
||||
{ "America/Chicago", "CST6CDT,M3.2.0,M11.1.0" },
|
||||
{ "America/Denver", "MST7MDT,M3.2.0,M11.1.0" },
|
||||
{ "America/Los_Angeles", "PST8PDT,M3.2.0,M11.1.0" },
|
||||
{ "Australia/Sydney", "AEST-10AEDT,M10.1.0,M4.1.0/3" },
|
||||
{ "UTC", "UTC0" },
|
||||
{ "GMT", "GMT0" },
|
||||
};
|
||||
|
||||
static const char *resolve_timezone(const char *tz_str)
|
||||
{
|
||||
if (!tz_str || !tz_str[0]) return NULL;
|
||||
|
||||
for (size_t i = 0; i < sizeof(tz_mappings) / sizeof(tz_mappings[0]); i++) {
|
||||
if (strcmp(tz_str, tz_mappings[i].name) == 0) {
|
||||
return tz_mappings[i].posix_tz;
|
||||
}
|
||||
}
|
||||
|
||||
return tz_str;
|
||||
}
|
||||
|
||||
static bool validate_timezone(const char *tz_str)
|
||||
{
|
||||
if (!tz_str || !tz_str[0]) return false;
|
||||
if (strlen(tz_str) > 64) return false;
|
||||
|
||||
for (size_t i = 0; i < sizeof(tz_mappings) / sizeof(tz_mappings[0]); i++) {
|
||||
if (strcmp(tz_str, tz_mappings[i].name) == 0) return true;
|
||||
}
|
||||
|
||||
if (strchr(tz_str, '+') || strchr(tz_str, '-') ||
|
||||
strcmp(tz_str, "UTC0") == 0 || strcmp(tz_str, "GMT0") == 0) {
|
||||
return true;
|
||||
}
|
||||
|
||||
return false;
|
||||
}
|
||||
|
||||
static esp_err_t save_timezone_nvs(const char *tz_str)
|
||||
{
|
||||
nvs_handle_t nvs;
|
||||
esp_err_t err = nvs_open("system_config", NVS_READWRITE, &nvs);
|
||||
if (err != ESP_OK) {
|
||||
ESP_LOGE(TAG, "Failed to open NVS: %s", esp_err_to_name(err));
|
||||
return err;
|
||||
}
|
||||
|
||||
err = nvs_set_str(nvs, MIMI_NVS_KEY_TIMEZONE, tz_str);
|
||||
if (err != ESP_OK) {
|
||||
ESP_LOGE(TAG, "Failed to save timezone: %s", esp_err_to_name(err));
|
||||
nvs_close(nvs);
|
||||
return err;
|
||||
}
|
||||
|
||||
err = nvs_commit(nvs);
|
||||
nvs_close(nvs);
|
||||
|
||||
if (err != ESP_OK) {
|
||||
ESP_LOGE(TAG, "Failed to commit NVS: %s", esp_err_to_name(err));
|
||||
return err;
|
||||
}
|
||||
|
||||
return ESP_OK;
|
||||
}
|
||||
|
||||
esp_err_t tool_set_timezone_execute(const char *input_json, char *output, size_t output_size)
|
||||
{
|
||||
ESP_LOGI(TAG, "Setting timezone...");
|
||||
|
||||
cJSON *root = cJSON_Parse(input_json);
|
||||
if (!root) {
|
||||
snprintf(output, output_size, "Error: invalid JSON input");
|
||||
return ESP_ERR_INVALID_ARG;
|
||||
}
|
||||
|
||||
cJSON *tz_item = cJSON_GetObjectItem(root, "timezone");
|
||||
if (!tz_item || !cJSON_IsString(tz_item)) {
|
||||
cJSON_Delete(root);
|
||||
snprintf(output, output_size, "Error: 'timezone' field required (string)");
|
||||
return ESP_ERR_INVALID_ARG;
|
||||
}
|
||||
|
||||
const char *input_tz = tz_item->valuestring;
|
||||
const char *resolved_tz = resolve_timezone(input_tz);
|
||||
|
||||
if (!resolved_tz || !validate_timezone(resolved_tz)) {
|
||||
cJSON_Delete(root);
|
||||
snprintf(output, output_size, "Error: invalid timezone format '%s'. Use POSIX format (e.g. CST-8) or city name (e.g. Asia/Shanghai)", input_tz);
|
||||
return ESP_ERR_INVALID_ARG;
|
||||
}
|
||||
|
||||
esp_err_t err = save_timezone_nvs(resolved_tz);
|
||||
if (err != ESP_OK) {
|
||||
cJSON_Delete(root);
|
||||
snprintf(output, output_size, "Error: failed to save timezone (%s)", esp_err_to_name(err));
|
||||
return err;
|
||||
}
|
||||
|
||||
setenv("TZ", resolved_tz, 1);
|
||||
tzset();
|
||||
|
||||
time_t now = time(NULL);
|
||||
struct tm tm_now;
|
||||
localtime_r(&now, &tm_now);
|
||||
char time_str[64];
|
||||
strftime(time_str, sizeof(time_str), "%Y-%m-%d %H:%M:%S %Z", &tm_now);
|
||||
|
||||
cJSON_Delete(root);
|
||||
snprintf(output, output_size, "Timezone set to '%s'. Current time: %s", resolved_tz, time_str);
|
||||
ESP_LOGI(TAG, "Timezone set to: %s, current time: %s", resolved_tz, time_str);
|
||||
|
||||
return ESP_OK;
|
||||
}
|
||||
11
main/tools/tool_set_timezone.h
Normal file
11
main/tools/tool_set_timezone.h
Normal file
@@ -0,0 +1,11 @@
|
||||
#pragma once
|
||||
|
||||
#include "esp_err.h"
|
||||
#include <stddef.h>
|
||||
|
||||
/**
|
||||
* Execute set_timezone tool.
|
||||
* Sets the system timezone via NVS and updates the TZ environment variable.
|
||||
* Input JSON: {"timezone": "CST-8"} or {"timezone": "Asia/Shanghai"}
|
||||
*/
|
||||
esp_err_t tool_set_timezone_execute(const char *input_json, char *output, size_t output_size);
|
||||
@@ -2,12 +2,18 @@
|
||||
#include "mimi_config.h"
|
||||
|
||||
#include <string.h>
|
||||
#include <stdbool.h>
|
||||
#include <inttypes.h>
|
||||
#include "esp_log.h"
|
||||
#include "esp_wifi.h"
|
||||
#include "esp_netif.h"
|
||||
#include "nvs_flash.h"
|
||||
#include "nvs.h"
|
||||
#include "esp_event.h"
|
||||
#include "freertos/FreeRTOS.h"
|
||||
#include "freertos/task.h"
|
||||
#include "freertos/event_groups.h"
|
||||
#include "freertos/timers.h"
|
||||
|
||||
static const char *TAG = "wifi";
|
||||
|
||||
@@ -15,20 +21,50 @@ 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 TimerHandle_t s_retry_timer = NULL;
|
||||
|
||||
static void retry_timer_callback(TimerHandle_t xTimer)
|
||||
{
|
||||
(void)xTimer;
|
||||
if (s_reconnect_enabled && !s_connected) {
|
||||
esp_wifi_connect();
|
||||
}
|
||||
}
|
||||
|
||||
static const char *wifi_reason_to_str(wifi_err_reason_t reason)
|
||||
{
|
||||
switch (reason) {
|
||||
#ifdef WIFI_REASON_AUTH_EXPIRE
|
||||
case WIFI_REASON_AUTH_EXPIRE: return "AUTH_EXPIRE";
|
||||
#endif
|
||||
#ifdef WIFI_REASON_AUTH_FAIL
|
||||
case WIFI_REASON_AUTH_FAIL: return "AUTH_FAIL";
|
||||
#endif
|
||||
#ifdef WIFI_REASON_ASSOC_EXPIRE
|
||||
case WIFI_REASON_ASSOC_EXPIRE: return "ASSOC_EXPIRE";
|
||||
#endif
|
||||
#ifdef WIFI_REASON_ASSOC_FAIL
|
||||
case WIFI_REASON_ASSOC_FAIL: return "ASSOC_FAIL";
|
||||
#endif
|
||||
#ifdef WIFI_REASON_HANDSHAKE_TIMEOUT
|
||||
case WIFI_REASON_HANDSHAKE_TIMEOUT: return "HANDSHAKE_TIMEOUT";
|
||||
#endif
|
||||
#ifdef WIFI_REASON_NO_AP_FOUND
|
||||
case WIFI_REASON_NO_AP_FOUND: return "NO_AP_FOUND";
|
||||
#endif
|
||||
#ifdef WIFI_REASON_BEACON_TIMEOUT
|
||||
case WIFI_REASON_BEACON_TIMEOUT: return "BEACON_TIMEOUT";
|
||||
#endif
|
||||
#ifdef WIFI_REASON_4WAY_HANDSHAKE_TIMEOUT
|
||||
case WIFI_REASON_4WAY_HANDSHAKE_TIMEOUT: return "4WAY_HANDSHAKE_TIMEOUT";
|
||||
#endif
|
||||
#ifdef WIFI_REASON_MIC_FAILURE
|
||||
case WIFI_REASON_MIC_FAILURE: return "MIC_FAILURE";
|
||||
#endif
|
||||
#ifdef WIFI_REASON_CONNECTION_FAIL
|
||||
case WIFI_REASON_CONNECTION_FAIL: return "CONNECTION_FAIL";
|
||||
#endif
|
||||
default: return "UNKNOWN";
|
||||
}
|
||||
}
|
||||
@@ -44,7 +80,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) {
|
||||
@@ -52,9 +88,10 @@ static void event_handler(void *arg, esp_event_base_t event_base,
|
||||
}
|
||||
ESP_LOGW(TAG, "Disconnected, retry %d/%d in %" PRIu32 "ms",
|
||||
s_retry_count + 1, MIMI_WIFI_MAX_RETRY, delay_ms);
|
||||
vTaskDelay(pdMS_TO_TICKS(delay_ms));
|
||||
esp_wifi_connect();
|
||||
s_retry_count++;
|
||||
/* Use timer instead of blocking vTaskDelay in event handler */
|
||||
if (s_retry_timer) xTimerStop(s_retry_timer, 0);
|
||||
xTimerChangePeriod(s_retry_timer, pdMS_TO_TICKS(delay_ms), 0);
|
||||
} else {
|
||||
ESP_LOGE(TAG, "Failed to connect after %d retries", MIMI_WIFI_MAX_RETRY);
|
||||
xEventGroupSetBits(s_wifi_event_group, WIFI_FAIL_BIT);
|
||||
@@ -73,6 +110,7 @@ static void event_handler(void *arg, esp_event_base_t event_base,
|
||||
esp_err_t wifi_manager_init(void)
|
||||
{
|
||||
s_wifi_event_group = xEventGroupCreate();
|
||||
s_retry_timer = xTimerCreate("wifi_retry", pdMS_TO_TICKS(1000), pdFALSE, NULL, retry_timer_callback);
|
||||
|
||||
ESP_ERROR_CHECK(esp_netif_init());
|
||||
esp_netif_create_default_wifi_sta();
|
||||
@@ -85,8 +123,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 +158,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 +270,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;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
37
spiffs_data/skills/gpio-control.md
Normal file
37
spiffs_data/skills/gpio-control.md
Normal 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."
|
||||
126
taolun.md
Normal file
126
taolun.md
Normal file
@@ -0,0 +1,126 @@
|
||||
# 讨论记录
|
||||
|
||||
---
|
||||
|
||||
## 讨论:ESP-IDF v6.0 编译适配
|
||||
|
||||
**日期**:2026-03-31
|
||||
**目标**:解决 ESP-IDF v6.0 编译失败问题,完成固件烧录
|
||||
|
||||
### 问题清单
|
||||
|
||||
#### 1. Flash 大小配置错误
|
||||
- **错误**:分区表需要 16MB,但 sdkconfig 配置为 2MB
|
||||
- **修复**:`sdkconfig` 中 `CONFIG_ESPTOOLPY_FLASHSIZE` 改为 16MB
|
||||
|
||||
#### 2. WiFi 断开原因码未定义
|
||||
- **错误**:`WIFI_REASON_ASSOC_EXPIRE` 等符号在 v6.0 中未定义
|
||||
- **修复**:`wifi_manager.c` 中所有 reason code 添加 `#ifdef` 保护
|
||||
|
||||
#### 3. CMakeLists.txt 缺少源文件
|
||||
- **错误**:`llm_provider.c` 未加入编译列表,导致链接错误
|
||||
- **修复**:添加 `ota/ota_manager.c` 到 SRCS
|
||||
|
||||
#### 4. 头文件缺失(共 16 处)
|
||||
| 文件 | 缺失头文件 | 原因 |
|
||||
|------|-----------|------|
|
||||
| `cli/serial_cli.c` | `llm/llm_provider.h` | `llm_provider_set_api_key` |
|
||||
| `llm/llm_provider.c` | `esp_http_client.h` | `esp_http_client_set_header` |
|
||||
| `bus/message_bus.c` | `freertos/FreeRTOS.h`, `freertos/queue.h` | `xQueueCreate`, `QueueHandle_t` |
|
||||
| `wifi/wifi_manager.c` | `esp_event.h` | `esp_event_handler_instance_register` |
|
||||
| `wifi/wifi_manager.c` | `freertos/FreeRTOS.h`, `freertos/task.h`, `freertos/event_groups.h` | `xEventGroupCreate`, `vTaskDelay` |
|
||||
| `ota/ota_manager.c` | `esp_system.h` | `esp_restart` |
|
||||
| `channels/telegram/telegram_bot.c` | `freertos/FreeRTOS.h`, `freertos/task.h` | `xTaskCreatePinnedToCore`, `vTaskDelay` |
|
||||
| `tools/tool_registry.c` | `<stdlib.h>` | `free()` |
|
||||
| `proxy/http_proxy.c` | `<sys/time.h>` | `struct timeval` |
|
||||
| `gateway/ws_server.c` | `<stdint.h>` | `uint8_t` |
|
||||
|
||||
### ESP-IDF v6.0 API 兼容性验证
|
||||
|
||||
以下 API 在 v6.0 中**仍然存在**,无需修改:
|
||||
- `esp_spiffs_info()` ✅
|
||||
- `esp_websocket_client_send_bin()` ✅
|
||||
- `esp_tls_set_conn_sockfd()` / `esp_tls_set_conn_state()` ✅
|
||||
- `esp_console_new_repl_uart()` / `esp_console_new_repl_usb_serial_jtag()` ✅
|
||||
- `esp_https_ota()` + `esp_https_ota_config_t` ✅
|
||||
- `esp_wifi_set_config()` ✅
|
||||
|
||||
### 烧录说明
|
||||
|
||||
ESP32-S3 使用 **USB 口**(内置 USB Serial/JTAG 控制器)烧录:
|
||||
```powershell
|
||||
idf.py -p COMx flash monitor
|
||||
```
|
||||
- 插 USB 口(标记为 `USB`),不是 UART 口
|
||||
- 如遇连接失败,按住 BOOT 键再插线进入下载模式
|
||||
|
||||
---
|
||||
|
||||
## 讨论:增加国内大模型厂商接入
|
||||
|
||||
**日期**:2026-03-31
|
||||
**目标**:为 MimiClaw 增加硅基流动和火山方舟(豆包模型)接入
|
||||
|
||||
### 项目现状
|
||||
- 当前支持:Anthropic (Claude)、OpenAI (GPT)
|
||||
- 运行平台:ESP32-S3,纯 C 语言
|
||||
- 交互方式:Telegram 机器人
|
||||
|
||||
### 国内厂商 API 兼容性
|
||||
- **硅基流动**:OpenAI 兼容,Base URL `https://api.siliconflow.cn/v1`
|
||||
- **火山方舟**:OpenAI 兼容,Base URL `https://ark.cn-beijing.volces.com/api/v3`
|
||||
|
||||
### 实现方案
|
||||
由于两者都提供 OpenAI 兼容 API,可复用现有 OpenAI 集成代码,只需:
|
||||
1. 修改 Base URL
|
||||
2. 调整认证方式(Bearer Token)
|
||||
3. 处理模型名称规范
|
||||
|
||||
### 待解决问题
|
||||
1. 认证方式差异确认
|
||||
2. 模型名称规范
|
||||
3. 工具调用格式兼容性验证
|
||||
|
||||
---
|
||||
|
||||
## 讨论:时区设置功能
|
||||
|
||||
**日期**:2026-04-01
|
||||
**目标**:为 MimiClaw 添加可配置的时区支持,默认改为中国时区
|
||||
|
||||
### 背景
|
||||
- 原默认时区为 `PST8PDT,M3.2.0,M11.1.0`(太平洋时间)
|
||||
- 需要支持用户自定义时区,特别是中国用户(UTC+8)
|
||||
- 交互方式从 Telegram 改为飞书
|
||||
|
||||
### 实现方案
|
||||
|
||||
#### 存储方式
|
||||
- **NVS 存储**:使用 `system_config` namespace,key 为 `timezone`
|
||||
- **Build-time 默认值**:`MIMI_TIMEZONE` 改为 `"CST-8"`
|
||||
- **优先级**:NVS 值 > Build-time 值
|
||||
|
||||
#### CLI 命令
|
||||
```
|
||||
set_timezone <TZ> # 例如: set_timezone CST-8 或 set_timezone Asia/Shanghai
|
||||
timezone_show # 显示当前时区配置和本地时间
|
||||
```
|
||||
|
||||
#### LLM 工具
|
||||
- 新增 `set_timezone` 工具,LLM 可通过对话设置时区
|
||||
- 支持 POSIX 格式(`CST-8`)和城市名(`Asia/Shanghai`)
|
||||
- 内置 18 个城市名映射表
|
||||
|
||||
### 改动文件
|
||||
| 文件 | 操作 |
|
||||
|------|------|
|
||||
| `main/mimi_config.h` | 默认时区改为 `CST-8`,添加 `MIMI_NVS_KEY_TIMEZONE` |
|
||||
| `main/tools/tool_set_timezone.h` | **新建** |
|
||||
| `main/tools/tool_set_timezone.c` | **新建** |
|
||||
| `main/tools/tool_registry.c` | include 新头文件 + 注册工具 |
|
||||
| `main/cli/serial_cli.c` | 添加 `set_timezone` / `timezone_show` 命令 |
|
||||
| `main/CMakeLists.txt` | 添加 `tool_set_timezone.c` 到 SRCS |
|
||||
|
||||
### 支持的时区格式
|
||||
- POSIX: `CST-8`, `JST-9`, `EST5EDT,M3.2.0,M11.1.0`, `UTC0`
|
||||
- 城市名: Asia/Shanghai, Asia/Tokyo, America/New_York 等 18 个预设
|
||||
Reference in New Issue
Block a user