mirror of
https://gitcode.com/JianFeeeee/homeagent-sdk.git
synced 2026-09-21 09:28:04 +00:00
Compare commits
13 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 5c1574be25 | |||
| 68497b4092 | |||
| 130f805b6e | |||
| cd1984e26e | |||
| 6184736fd4 | |||
| 81bfdfce1d | |||
| e3f93e254b | |||
| d57c5eaf3e | |||
| 16b4a56ee8 | |||
| 2e6d037bb9 | |||
| cf77bf389e | |||
| fc876c5554 | |||
| 5c5df9cfb9 |
2
.gitignore
vendored
2
.gitignore
vendored
@ -1,6 +1,8 @@
|
|||||||
# Build artifacts
|
# Build artifacts
|
||||||
*.so
|
*.so
|
||||||
*.dll
|
*.dll
|
||||||
|
*.o
|
||||||
|
*.exe
|
||||||
*.hmap
|
*.hmap
|
||||||
plugin.json
|
plugin.json
|
||||||
|
|
||||||
|
|||||||
318
README.md
318
README.md
@ -308,6 +308,324 @@ enabled := sdk.AutoRestart()
|
|||||||
| [rss](example/rss) | Go | RSS 订阅 |
|
| [rss](example/rss) | Go | RSS 订阅 |
|
||||||
| [sanitizer](example/sanitizer) | Go | 内容清洗/安全过滤 |
|
| [sanitizer](example/sanitizer) | Go | 内容清洗/安全过滤 |
|
||||||
|
|
||||||
|
## Remote Device SDK
|
||||||
|
|
||||||
|
用于开发**远程设备接入适配器**的 C 语言 SDK,零外部依赖,兼容嵌入式平台。
|
||||||
|
|
||||||
|
### 架构
|
||||||
|
|
||||||
|
```
|
||||||
|
┌─────────────────────────────────────────────────┐
|
||||||
|
│ ha_remotedevice (C SDK) │
|
||||||
|
│ 协议引擎 │ WS 帧 │ JSON │ 状态机 │ 传输抽象 │
|
||||||
|
└──────────┬──────────────────────────────────────┘
|
||||||
|
│ 同一份 C 代码,设备端和 App 端共用
|
||||||
|
┌──────┴──────────────────┐
|
||||||
|
▼ ▼
|
||||||
|
┌──────────────┐ ┌──────────────────────────┐
|
||||||
|
│ ESP32 裸机 │ │ Linux 设备上的 App │
|
||||||
|
│ 纯 C 直调 │ │ (Python ctypes / Go CGo / │
|
||||||
|
│ 简单命令处理 │ │ Node addon / C# P/Invoke) │
|
||||||
|
└──────────────┘ └──────────────────────────┘
|
||||||
|
```
|
||||||
|
|
||||||
|
### 声明式 API 设计
|
||||||
|
|
||||||
|
设备在代码中声明**自己是什么**、**能做什么**、**支持哪些命令**,每个命令对应独立处理函数,SDK 自动分发并回执结果:
|
||||||
|
|
||||||
|
```c
|
||||||
|
#include "ha_remotedevice.h"
|
||||||
|
|
||||||
|
/* 声明能力 */
|
||||||
|
const char *caps[] = {"camera", "status", NULL};
|
||||||
|
|
||||||
|
/* 声明式命令处理表:每个命令绑定独立处理函数 */
|
||||||
|
static ha_status_t handle_camerasue(const char *req_id, const char *args,
|
||||||
|
ha_cmd_result_t *result, void *userdata) {
|
||||||
|
(void)req_id; (void)userdata;
|
||||||
|
int duration = args[0] ? atoi(args) : 0;
|
||||||
|
// 拍照/录像...
|
||||||
|
result->status = 0;
|
||||||
|
result->output = "data:image/jpeg;base64,..."; // SDK 自动回执
|
||||||
|
return HA_OK;
|
||||||
|
}
|
||||||
|
|
||||||
|
ha_cmd_handler_def_t handlers[] = {
|
||||||
|
{.command = "shell", .handler = handle_shell},
|
||||||
|
{.command = "camerasue", .handler = handle_camerasue},
|
||||||
|
{.command = "screensee", .handler = handle_screensee},
|
||||||
|
{.command = "speakeruse", .handler = handle_speakeruse},
|
||||||
|
{.command = NULL}, /* 标记结束 */
|
||||||
|
};
|
||||||
|
|
||||||
|
ha_config_t config = {
|
||||||
|
.transport = my_transport, // 用户实现 4 个函数
|
||||||
|
.server = "192.168.1.100:9890",
|
||||||
|
.token = "my-token",
|
||||||
|
.device = {
|
||||||
|
.device_id = "esp32-cam-1",
|
||||||
|
.name = "门口摄像头",
|
||||||
|
.kind = "camera",
|
||||||
|
.caps = caps,
|
||||||
|
},
|
||||||
|
.handlers = handlers, // 声明式命令处理表
|
||||||
|
.on_state = my_state_handler,
|
||||||
|
};
|
||||||
|
|
||||||
|
ha_client_t *client = ha_client_new(&config);
|
||||||
|
ha_client_start(client);
|
||||||
|
while (1) {
|
||||||
|
ha_client_process(client); // 主循环处理
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### 传输层抽象
|
||||||
|
|
||||||
|
用户只需实现 4 个函数,适配不同平台:
|
||||||
|
|
||||||
|
```c
|
||||||
|
ha_transport_t my_transport = {
|
||||||
|
.connect = my_tcp_connect, // 建立 TCP 连接
|
||||||
|
.send = my_tcp_send, // 发送数据
|
||||||
|
.recv = my_tcp_recv, // 接收数据(阻塞)
|
||||||
|
.close = my_tcp_close, // 关闭连接
|
||||||
|
.ctx = &my_platform_ctx,
|
||||||
|
};
|
||||||
|
```
|
||||||
|
|
||||||
|
### 支持的协议
|
||||||
|
|
||||||
|
| 功能 | API |
|
||||||
|
|------|-----|
|
||||||
|
| WS 连接 + 握手 | `ha_client_start` 自动完成 |
|
||||||
|
| 设备注册 (hello/bind) | 启动时自动发送 |
|
||||||
|
| 命令接收 (shell/homeagent) | `handlers` 表声明式注册,SDK 自动分发 |
|
||||||
|
| 命令回执 | `ha_client_send_result` |
|
||||||
|
| 二进制分块(录像等) | `ha_client_send_data_chunked` |
|
||||||
|
| TTS 音频接收 | `on_binary` 回调 |
|
||||||
|
| 事件上报 | `ha_client_send_event` |
|
||||||
|
| 状态上报 | `ha_client_send_status` |
|
||||||
|
| 心跳保持 | 自动 ping/pong |
|
||||||
|
|
||||||
|
### 使用方式
|
||||||
|
|
||||||
|
通过 `plugindev` 工具链初始化项目:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
plugindev init my-adapter --type remotedevice
|
||||||
|
```
|
||||||
|
|
||||||
|
生成 `main.c` + `CMakeLists.txt`,可直接编译或作为三方库引入:
|
||||||
|
|
||||||
|
```cmake
|
||||||
|
add_subdirectory(path/to/ha_remotedevice)
|
||||||
|
target_link_libraries(my_app ha_remotedevice)
|
||||||
|
target_include_directories(my_app PRIVATE ${HA_REMOTEDEVICE_INCLUDE_DIR})
|
||||||
|
```
|
||||||
|
|
||||||
|
### 快速接入指南
|
||||||
|
|
||||||
|
以下是从零到设备成功接入 HomeAgent 的完整步骤。
|
||||||
|
|
||||||
|
#### 1. 准备工作
|
||||||
|
|
||||||
|
在 HomeAgent 平台上创建接入令牌:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# 在 HomeAgent 服务端创建一个设备接入令牌
|
||||||
|
curl -X POST http://<homeagent-server>:8080/api/v1/device/token \
|
||||||
|
-H "Content-Type: application/json" \
|
||||||
|
-d '{"device_id":"esp32-cam-1","name":"门口摄像头","kind":"camera"}'
|
||||||
|
# 返回: {"token":"ha-dev-token-xxxxx"}
|
||||||
|
```
|
||||||
|
|
||||||
|
记录下返回的 `token`,设备端配置时使用。
|
||||||
|
|
||||||
|
#### 2. 实现传输层(4 个函数)
|
||||||
|
|
||||||
|
根据你的平台实现 `ha_transport_t` 的 4 个函数指针。以下是几种常见场景:
|
||||||
|
|
||||||
|
**场景 A:带 TCP/IP 栈的嵌入式设备(如 ESP32 + lwIP)**
|
||||||
|
|
||||||
|
```c
|
||||||
|
#include "ha_remotedevice.h"
|
||||||
|
#include "lwip/sockets.h"
|
||||||
|
|
||||||
|
static int esp_connect(void *ctx, const char *host, uint16_t port) {
|
||||||
|
struct sockaddr_in addr;
|
||||||
|
int sock = socket(AF_INET, SOCK_STREAM, 0);
|
||||||
|
if (sock < 0) return -1;
|
||||||
|
addr.sin_family = AF_INET;
|
||||||
|
addr.sin_port = htons(port);
|
||||||
|
inet_pton(AF_INET, host, &addr.sin_addr);
|
||||||
|
int ret = connect(sock, (struct sockaddr *)&addr, sizeof(addr));
|
||||||
|
if (ret < 0) { closesocket(sock); return -1; }
|
||||||
|
*(int *)ctx = sock;
|
||||||
|
return 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
static int esp_send(void *ctx, const uint8_t *data, int len) {
|
||||||
|
int sock = *(int *)ctx;
|
||||||
|
return send(sock, (const char *)data, len, 0);
|
||||||
|
}
|
||||||
|
|
||||||
|
static int esp_recv(void *ctx, uint8_t *buf, int len) {
|
||||||
|
int sock = *(int *)ctx;
|
||||||
|
return recv(sock, (char *)buf, len, 0);
|
||||||
|
}
|
||||||
|
|
||||||
|
static void esp_close(void *ctx) {
|
||||||
|
int sock = *(int *)ctx;
|
||||||
|
closesocket(sock);
|
||||||
|
}
|
||||||
|
|
||||||
|
int esp_ctx = -1;
|
||||||
|
ha_transport_t transport = {
|
||||||
|
.connect = esp_connect,
|
||||||
|
.send = esp_send,
|
||||||
|
.recv = esp_recv,
|
||||||
|
.close = esp_close,
|
||||||
|
.ctx = &esp_ctx,
|
||||||
|
};
|
||||||
|
```
|
||||||
|
|
||||||
|
**场景 B:通过串口(UART)连接透传模块**
|
||||||
|
|
||||||
|
```c
|
||||||
|
static int uart_connect(void *ctx, const char *host, uint16_t port) {
|
||||||
|
(void)host; (void)port;
|
||||||
|
// 初始化 UART,波特率 115200
|
||||||
|
return uart_init((uart_ctx_t *)ctx, 115200);
|
||||||
|
}
|
||||||
|
|
||||||
|
static int uart_send(void *ctx, const uint8_t *data, int len) {
|
||||||
|
return uart_write((uart_ctx_t *)ctx, data, len);
|
||||||
|
}
|
||||||
|
|
||||||
|
static int uart_recv(void *ctx, uint8_t *buf, int len) {
|
||||||
|
return uart_read((uart_ctx_t *)ctx, buf, len);
|
||||||
|
}
|
||||||
|
|
||||||
|
static void uart_close(void *ctx) {
|
||||||
|
uart_deinit((uart_ctx_t *)ctx);
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
> 注意:UART 透传时,另一端需运行一个 TCP 桥接程序,将串口数据转发到 HomeAgent 的 WebSocket 端口。
|
||||||
|
|
||||||
|
#### 3. 声明设备能力和命令处理
|
||||||
|
|
||||||
|
```c
|
||||||
|
#include "ha_remotedevice.h"
|
||||||
|
|
||||||
|
/* 声明设备能力 */
|
||||||
|
const char *caps[] = {"camera", "speaker", "status", NULL};
|
||||||
|
|
||||||
|
/* 处理 camerasue 命令(拍照) */
|
||||||
|
static ha_status_t handle_camera(const char *req_id, const char *args,
|
||||||
|
ha_cmd_result_t *result, void *userdata) {
|
||||||
|
(void)req_id; (void)userdata;
|
||||||
|
int duration = args[0] ? atoi(args) : 0; // 参数:录像时长
|
||||||
|
|
||||||
|
// 拍照或录像,将结果填入 result
|
||||||
|
result->status = 0;
|
||||||
|
result->output = "data:image/jpeg;base64,/9j/4AAQ..."; // base64 图像数据
|
||||||
|
return HA_OK;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* 处理 shell 命令 */
|
||||||
|
static ha_status_t handle_shell(const char *req_id, const char *args,
|
||||||
|
ha_cmd_result_t *result, void *userdata) {
|
||||||
|
(void)req_id; (void)userdata;
|
||||||
|
// 执行 shell 命令,args 为完整命令字符串
|
||||||
|
result->status = 0;
|
||||||
|
result->output = "command executed";
|
||||||
|
return HA_OK;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* 声明式命令处理表 */
|
||||||
|
ha_cmd_handler_def_t handlers[] = {
|
||||||
|
{.command = "shell", .handler = handle_shell},
|
||||||
|
{.command = "camerasue", .handler = handle_camera},
|
||||||
|
{.command = "screensee", .handler = handle_camera},
|
||||||
|
{.command = "speakeruse", .handler = handle_speaker},
|
||||||
|
{.command = NULL}, /* 标记结束 */
|
||||||
|
};
|
||||||
|
```
|
||||||
|
|
||||||
|
#### 4. 配置并启动客户端
|
||||||
|
|
||||||
|
```c
|
||||||
|
ha_config_t config = {
|
||||||
|
.transport = transport, // 传输层实现
|
||||||
|
.server = "192.168.1.100:9890", // HomeAgent 服务端地址
|
||||||
|
.token = "ha-dev-token-xxxxx", // 第 1 步获取的令牌
|
||||||
|
.device = {
|
||||||
|
.device_id = "esp32-cam-1",
|
||||||
|
.name = "门口摄像头",
|
||||||
|
.kind = "camera",
|
||||||
|
.caps = caps,
|
||||||
|
.info_json = "{\"chip\":\"ESP32-S3\",\"firmware\":\"v1.0\"}",
|
||||||
|
},
|
||||||
|
.handlers = handlers, // 命令处理表
|
||||||
|
.on_binary = on_binary_data, // 接收 TTS 音频等二进制数据
|
||||||
|
.on_state = on_state_change, // 连接状态变化回调
|
||||||
|
.ping_interval = 30, // 心跳间隔秒数
|
||||||
|
};
|
||||||
|
|
||||||
|
ha_client_t *client = ha_client_new(&config);
|
||||||
|
ha_status_t ret = ha_client_start(client);
|
||||||
|
if (ret != HA_OK) {
|
||||||
|
printf("设备接入失败: %d\n", ret);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* 主循环 */
|
||||||
|
while (1) {
|
||||||
|
ha_client_process(client); // 处理协议帧、心跳、命令分发
|
||||||
|
|
||||||
|
/* 可选:设备主动上报事件 */
|
||||||
|
ha_client_send_event(client, "motion_detected",
|
||||||
|
"{\"zone\":\"front_door\",\"confidence\":0.95}");
|
||||||
|
|
||||||
|
/* 可选:上报设备状态 */
|
||||||
|
ha_client_send_status(client, "online");
|
||||||
|
|
||||||
|
vTaskDelay(100 / portTICK_PERIOD_MS); // 嵌入式 RTOS 风格延时
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
#### 5. 验证连接
|
||||||
|
|
||||||
|
在 HomeAgent 服务端检查设备是否在线:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# 查看已注册设备列表
|
||||||
|
curl http://<homeagent-server>:8080/api/v1/device/list
|
||||||
|
# 预期输出包含: {"device_id":"esp32-cam-1","status":"online",...}
|
||||||
|
|
||||||
|
# 向设备发送命令(测试 camerasue)
|
||||||
|
curl -X POST http://<homeagent-server>:8080/api/v1/device/esp32-cam-1/cmd \
|
||||||
|
-H "Content-Type: application/json" \
|
||||||
|
-d '{"cmd":"camerasue","args":"3"}'
|
||||||
|
# 预期返回: {"status":"ok","result":"data:image/jpeg;base64,..."}
|
||||||
|
```
|
||||||
|
|
||||||
|
#### 6. 调试技巧
|
||||||
|
|
||||||
|
| 问题 | 检查点 |
|
||||||
|
|------|--------|
|
||||||
|
| 连接失败 | 确认 `server` 地址和端口可通;检查 `token` 是否正确 |
|
||||||
|
| WS 握手失败 | 确认 HomeAgent 服务端已开启 WebSocket 支持 |
|
||||||
|
| 命令无响应 | 确认 `handlers` 表中注册了对应命令名;检查 `on_binary` 是否配置 |
|
||||||
|
| 断线重连 | `max_reconnect` 控制重连次数,-1 为无限重连 |
|
||||||
|
| 内存不足(嵌入式) | 定义 `HA_NO_ALLOC` 宏禁用动态内存分配 |
|
||||||
|
|
||||||
|
### 位置
|
||||||
|
|
||||||
|
- **SDK 源码**: `remotedevice/`
|
||||||
|
- **plugindev 模板**: `plugindev init --type remotedevice`
|
||||||
|
|
||||||
## 构建与安装
|
## 构建与安装
|
||||||
|
|
||||||
### 构建
|
### 构建
|
||||||
|
|||||||
299
README_EN.md
299
README_EN.md
@ -265,6 +265,305 @@ Internal plugins (platform built-in) have full SDK access including SocialAPI wr
|
|||||||
| [rss](example/rss) | Go | RSS subscriptions |
|
| [rss](example/rss) | Go | RSS subscriptions |
|
||||||
| [sanitizer](example/sanitizer) | Go | Content sanitization / safety filtering |
|
| [sanitizer](example/sanitizer) | Go | Content sanitization / safety filtering |
|
||||||
|
|
||||||
|
## Remote Device SDK
|
||||||
|
|
||||||
|
A C language SDK for developing **remote device access adapters** with zero external dependencies, compatible with embedded platforms.
|
||||||
|
|
||||||
|
### Architecture
|
||||||
|
|
||||||
|
```
|
||||||
|
┌─────────────────────────────────────────────────┐
|
||||||
|
│ ha_remotedevice (C SDK) │
|
||||||
|
│ Protocol Engine │ WS Frames │ JSON │ State │
|
||||||
|
│ Machine │ Transport Abstraction │
|
||||||
|
└──────────┬──────────────────────────────────────┘
|
||||||
|
│ Same C code, shared by device & app
|
||||||
|
┌──────┴──────────────────┐
|
||||||
|
▼ ▼
|
||||||
|
┌──────────────┐ ┌──────────────────────────┐
|
||||||
|
│ ESP32 Bare │ │ Linux App │
|
||||||
|
│ Pure C │ │ (Python ctypes / Go CGo /│
|
||||||
|
│ Simple Cmd │ │ Node addon / C# P/Invoke)│
|
||||||
|
└──────────────┘ └──────────────────────────┘
|
||||||
|
```
|
||||||
|
|
||||||
|
### Declarative API Design
|
||||||
|
|
||||||
|
The device declares **what it is** and **what it can do** in code. The SDK handles all protocol details automatically:
|
||||||
|
|
||||||
|
```c
|
||||||
|
#include "ha_remotedevice.h"
|
||||||
|
|
||||||
|
/* Declare capabilities */
|
||||||
|
const char *caps[] = {"camera", "status", NULL};
|
||||||
|
|
||||||
|
ha_config_t config = {
|
||||||
|
.transport = my_transport, // User implements 4 functions
|
||||||
|
.server = "192.168.1.100:9890",
|
||||||
|
.token = "my-token",
|
||||||
|
.device = {
|
||||||
|
.device_id = "esp32-cam-1",
|
||||||
|
.name = "Front Door Camera",
|
||||||
|
.kind = "camera",
|
||||||
|
.caps = caps,
|
||||||
|
},
|
||||||
|
.on_cmd = my_cmd_handler, // Called when receiving commands
|
||||||
|
.on_binary = my_data_handler, // Called on binary data (TTS audio, etc.)
|
||||||
|
.on_state = my_state_handler, // Connection state changes
|
||||||
|
};
|
||||||
|
|
||||||
|
ha_client_t *client = ha_client_new(&config);
|
||||||
|
ha_client_start(client);
|
||||||
|
while (1) {
|
||||||
|
ha_client_process(client); // Main loop processing
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### Transport Layer Abstraction
|
||||||
|
|
||||||
|
Users only need to implement 4 functions to adapt to different platforms:
|
||||||
|
|
||||||
|
```c
|
||||||
|
ha_transport_t my_transport = {
|
||||||
|
.connect = my_tcp_connect, // Establish TCP connection
|
||||||
|
.send = my_tcp_send, // Send data
|
||||||
|
.recv = my_tcp_recv, // Receive data (blocking)
|
||||||
|
.close = my_tcp_close, // Close connection
|
||||||
|
.ctx = &my_platform_ctx,
|
||||||
|
};
|
||||||
|
```
|
||||||
|
|
||||||
|
### Protocol Support
|
||||||
|
|
||||||
|
| Feature | API |
|
||||||
|
|---------|-----|
|
||||||
|
| WS connection + handshake | Automatic via `ha_client_start` |
|
||||||
|
| Device registration (hello/bind) | Automatic on startup |
|
||||||
|
| Command receive (shell/homeagent) | `on_cmd` callback |
|
||||||
|
| Command result | `ha_client_send_result` |
|
||||||
|
| Binary chunked transfer (video) | `ha_client_send_data_chunked` |
|
||||||
|
| TTS audio receive | `on_binary` callback |
|
||||||
|
| Event reporting | `ha_client_send_event` |
|
||||||
|
| Status reporting | `ha_client_send_status` |
|
||||||
|
| Heartbeat keepalive | Automatic ping/pong |
|
||||||
|
|
||||||
|
### Usage
|
||||||
|
|
||||||
|
Initialize a project via the `plugindev` toolchain:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
plugindev init my-adapter --type remotedevice
|
||||||
|
```
|
||||||
|
|
||||||
|
Generates `main.c` + `CMakeLists.txt`, can be built directly or used as a third-party library:
|
||||||
|
|
||||||
|
```cmake
|
||||||
|
add_subdirectory(path/to/ha_remotedevice)
|
||||||
|
target_link_libraries(my_app ha_remotedevice)
|
||||||
|
target_include_directories(my_app PRIVATE ${HA_REMOTEDEVICE_INCLUDE_DIR})
|
||||||
|
```
|
||||||
|
|
||||||
|
### Quick Start Guide
|
||||||
|
|
||||||
|
A complete step-by-step guide from zero to a device successfully connected to HomeAgent.
|
||||||
|
|
||||||
|
#### Step 1: Preparation
|
||||||
|
|
||||||
|
Create an access token on the HomeAgent platform:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# Create a device access token on the HomeAgent server
|
||||||
|
curl -X POST http://<homeagent-server>:8080/api/v1/device/token \
|
||||||
|
-H "Content-Type: application/json" \
|
||||||
|
-d '{"device_id":"esp32-cam-1","name":"Front Door Camera","kind":"camera"}'
|
||||||
|
# Returns: {"token":"ha-dev-token-xxxxx"}
|
||||||
|
```
|
||||||
|
|
||||||
|
Save the returned `token` — you'll need it in the device configuration.
|
||||||
|
|
||||||
|
#### Step 2: Implement the Transport Layer (4 functions)
|
||||||
|
|
||||||
|
Implement the 4 function pointers of `ha_transport_t` for your platform. Here are common scenarios:
|
||||||
|
|
||||||
|
**Scenario A: Embedded device with TCP/IP stack (e.g., ESP32 + lwIP)**
|
||||||
|
|
||||||
|
```c
|
||||||
|
#include "ha_remotedevice.h"
|
||||||
|
#include "lwip/sockets.h"
|
||||||
|
|
||||||
|
static int esp_connect(void *ctx, const char *host, uint16_t port) {
|
||||||
|
struct sockaddr_in addr;
|
||||||
|
int sock = socket(AF_INET, SOCK_STREAM, 0);
|
||||||
|
if (sock < 0) return -1;
|
||||||
|
addr.sin_family = AF_INET;
|
||||||
|
addr.sin_port = htons(port);
|
||||||
|
inet_pton(AF_INET, host, &addr.sin_addr);
|
||||||
|
int ret = connect(sock, (struct sockaddr *)&addr, sizeof(addr));
|
||||||
|
if (ret < 0) { closesocket(sock); return -1; }
|
||||||
|
*(int *)ctx = sock;
|
||||||
|
return 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
static int esp_send(void *ctx, const uint8_t *data, int len) {
|
||||||
|
int sock = *(int *)ctx;
|
||||||
|
return send(sock, (const char *)data, len, 0);
|
||||||
|
}
|
||||||
|
|
||||||
|
static int esp_recv(void *ctx, uint8_t *buf, int len) {
|
||||||
|
int sock = *(int *)ctx;
|
||||||
|
return recv(sock, (char *)buf, len, 0);
|
||||||
|
}
|
||||||
|
|
||||||
|
static void esp_close(void *ctx) {
|
||||||
|
int sock = *(int *)ctx;
|
||||||
|
closesocket(sock);
|
||||||
|
}
|
||||||
|
|
||||||
|
int esp_ctx = -1;
|
||||||
|
ha_transport_t transport = {
|
||||||
|
.connect = esp_connect,
|
||||||
|
.send = esp_send,
|
||||||
|
.recv = esp_recv,
|
||||||
|
.close = esp_close,
|
||||||
|
.ctx = &esp_ctx,
|
||||||
|
};
|
||||||
|
```
|
||||||
|
|
||||||
|
**Scenario B: Serial (UART) passthrough module**
|
||||||
|
|
||||||
|
```c
|
||||||
|
static int uart_connect(void *ctx, const char *host, uint16_t port) {
|
||||||
|
(void)host; (void)port;
|
||||||
|
return uart_init((uart_ctx_t *)ctx, 115200);
|
||||||
|
}
|
||||||
|
|
||||||
|
static int uart_send(void *ctx, const uint8_t *data, int len) {
|
||||||
|
return uart_write((uart_ctx_t *)ctx, data, len);
|
||||||
|
}
|
||||||
|
|
||||||
|
static int uart_recv(void *ctx, uint8_t *buf, int len) {
|
||||||
|
return uart_read((uart_ctx_t *)ctx, buf, len);
|
||||||
|
}
|
||||||
|
|
||||||
|
static void uart_close(void *ctx) {
|
||||||
|
uart_deinit((uart_ctx_t *)ctx);
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
> Note: For UART passthrough, a TCP bridge program must run on the other end to forward serial data to the HomeAgent WebSocket port.
|
||||||
|
|
||||||
|
#### Step 3: Declare Device Capabilities and Command Handlers
|
||||||
|
|
||||||
|
```c
|
||||||
|
#include "ha_remotedevice.h"
|
||||||
|
|
||||||
|
/* Declare device capabilities */
|
||||||
|
const char *caps[] = {"camera", "speaker", "status", NULL};
|
||||||
|
|
||||||
|
/* Handle camerasue command (take photo) */
|
||||||
|
static ha_status_t handle_camera(const char *req_id, const char *args,
|
||||||
|
ha_cmd_result_t *result, void *userdata) {
|
||||||
|
(void)req_id; (void)userdata;
|
||||||
|
int duration = args[0] ? atoi(args) : 0;
|
||||||
|
|
||||||
|
// Capture image, fill the result
|
||||||
|
result->status = 0;
|
||||||
|
result->output = "data:image/jpeg;base64,/9j/4AAQ..."; // base64 image data
|
||||||
|
return HA_OK;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Handle shell command */
|
||||||
|
static ha_status_t handle_shell(const char *req_id, const char *args,
|
||||||
|
ha_cmd_result_t *result, void *userdata) {
|
||||||
|
(void)req_id; (void)userdata;
|
||||||
|
result->status = 0;
|
||||||
|
result->output = "command executed";
|
||||||
|
return HA_OK;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Declarative command handler table */
|
||||||
|
ha_cmd_handler_def_t handlers[] = {
|
||||||
|
{.command = "shell", .handler = handle_shell},
|
||||||
|
{.command = "camerasue", .handler = handle_camera},
|
||||||
|
{.command = "screensee", .handler = handle_camera},
|
||||||
|
{.command = "speakeruse", .handler = handle_speaker},
|
||||||
|
{.command = NULL}, /* terminator */
|
||||||
|
};
|
||||||
|
```
|
||||||
|
|
||||||
|
#### Step 4: Configure and Start the Client
|
||||||
|
|
||||||
|
```c
|
||||||
|
ha_config_t config = {
|
||||||
|
.transport = transport, // Transport layer implementation
|
||||||
|
.server = "192.168.1.100:9890", // HomeAgent server address
|
||||||
|
.token = "ha-dev-token-xxxxx", // Token from Step 1
|
||||||
|
.device = {
|
||||||
|
.device_id = "esp32-cam-1",
|
||||||
|
.name = "Front Door Camera",
|
||||||
|
.kind = "camera",
|
||||||
|
.caps = caps,
|
||||||
|
.info_json = "{\"chip\":\"ESP32-S3\",\"firmware\":\"v1.0\"}",
|
||||||
|
},
|
||||||
|
.handlers = handlers, // Command handler table
|
||||||
|
.on_binary = on_binary_data, // Receive TTS audio etc.
|
||||||
|
.on_state = on_state_change, // Connection state callback
|
||||||
|
.ping_interval = 30,
|
||||||
|
};
|
||||||
|
|
||||||
|
ha_client_t *client = ha_client_new(&config);
|
||||||
|
ha_status_t ret = ha_client_start(client);
|
||||||
|
if (ret != HA_OK) {
|
||||||
|
printf("Device connection failed: %d\n", ret);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Main loop */
|
||||||
|
while (1) {
|
||||||
|
ha_client_process(client); // Process protocol frames, heartbeats, commands
|
||||||
|
|
||||||
|
/* Optional: device-initiated event reporting */
|
||||||
|
ha_client_send_event(client, "motion_detected",
|
||||||
|
"{\"zone\":\"front_door\",\"confidence\":0.95}");
|
||||||
|
|
||||||
|
/* Optional: report device status */
|
||||||
|
ha_client_send_status(client, "online");
|
||||||
|
|
||||||
|
vTaskDelay(100 / portTICK_PERIOD_MS); // RTOS-style delay
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
#### Step 5: Verify the Connection
|
||||||
|
|
||||||
|
Check if the device is online on the HomeAgent server:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# List registered devices
|
||||||
|
curl http://<homeagent-server>:8080/api/v1/device/list
|
||||||
|
# Expected output includes: {"device_id":"esp32-cam-1","status":"online",...}
|
||||||
|
|
||||||
|
# Send a command to the device (test camerasue)
|
||||||
|
curl -X POST http://<homeagent-server>:8080/api/v1/device/esp32-cam-1/cmd \
|
||||||
|
-H "Content-Type: application/json" \
|
||||||
|
-d '{"cmd":"camerasue","args":"3"}'
|
||||||
|
# Expected: {"status":"ok","result":"data:image/jpeg;base64,..."}
|
||||||
|
```
|
||||||
|
|
||||||
|
#### Step 6: Debugging Tips
|
||||||
|
|
||||||
|
| Issue | Check |
|
||||||
|
|-------|-------|
|
||||||
|
| Connection failed | Verify `server` address and port are reachable; check `token` |
|
||||||
|
| WS handshake failed | Verify HomeAgent server WebSocket support is enabled |
|
||||||
|
| Command not responding | Confirm the command name is registered in `handlers` table; check `on_binary` |
|
||||||
|
| Reconnection issues | `max_reconnect` controls retry count; -1 = infinite |
|
||||||
|
| Low memory (embedded) | Define `HA_NO_ALLOC` to disable dynamic memory allocation |
|
||||||
|
|
||||||
|
### Location
|
||||||
|
|
||||||
|
- **SDK Source**: `remotedevice/`
|
||||||
|
- **plugindev template**: `plugindev init --type remotedevice`
|
||||||
|
|
||||||
## Building & Installing
|
## Building & Installing
|
||||||
|
|
||||||
### Build
|
### Build
|
||||||
|
|||||||
@ -2,14 +2,18 @@
|
|||||||
"name": "a2a",
|
"name": "a2a",
|
||||||
"name_zh": "A2A 代理通信",
|
"name_zh": "A2A 代理通信",
|
||||||
"name_en": "A2A Agent Communication",
|
"name_en": "A2A Agent Communication",
|
||||||
"version": "1.0.0",
|
"version": "1.3.0",
|
||||||
"description": "Agent-to-Agent 协议通信插件,支持双向 A2A 通信:可查询其他 Agent 并回复其请求。提供 HTTP 服务端暴露本 Agent 能力。",
|
"description": "Agent-to-Agent 协议通信插件,支持双向 A2A 通信:可查询其他 Agent 并回复其请求。提供 HTTP 服务端暴露本 Agent 能力。",
|
||||||
"author": "HomeAgent",
|
"author": "HomeAgent",
|
||||||
"entry": "plugin.so",
|
"entry": "plugin.so",
|
||||||
"tags": ["a2a", "agent", "interop"],
|
"tags": [
|
||||||
|
"a2a",
|
||||||
|
"agent",
|
||||||
|
"interop"
|
||||||
|
],
|
||||||
"targets": "linux/amd64",
|
"targets": "linux/amd64",
|
||||||
"outdir": "dist",
|
"outdir": "dist",
|
||||||
"bundle": true,
|
"bundle": true,
|
||||||
"replaces": {},
|
"replaces": {},
|
||||||
"source_dirs": []
|
"source_dirs": []
|
||||||
}
|
}
|
||||||
@ -21,15 +21,47 @@ type Plugin struct {
|
|||||||
srvMu sync.Mutex
|
srvMu sync.Mutex
|
||||||
server *http.Server
|
server *http.Server
|
||||||
serverAddr string
|
serverAddr string
|
||||||
|
|
||||||
|
// 会话表:session_id → 上下文前缀。A2A 无状态协议下由插件侧维护
|
||||||
|
// 多轮上下文:同 session 的后续请求会把之前的对话拼进注入文本。
|
||||||
|
sessMu sync.Mutex
|
||||||
|
sessions map[string]*a2aSession
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// a2aSession 记录一个会话的轮次历史,用于延续上下文。
|
||||||
|
type a2aSession struct {
|
||||||
|
ID string
|
||||||
|
History []string // 轮次文本 [user1, agent1, user2, agent2, ...]
|
||||||
|
LastUsed time.Time
|
||||||
|
}
|
||||||
|
|
||||||
|
// maxSessionTurns 单会话保留的最大轮次对数(防上下文无限膨胀)。
|
||||||
|
const maxSessionTurns = 10
|
||||||
|
|
||||||
|
// sessionGCPeriod 会话过期清理周期;超过 2 小时未用的会话回收。
|
||||||
|
const sessionGCPeriod = 30 * time.Minute
|
||||||
|
|
||||||
func (p *Plugin) Name() string { return p.name }
|
func (p *Plugin) Name() string { return p.name }
|
||||||
|
|
||||||
func (p *Plugin) Start(s *sdk.PluginSDK) error {
|
func (p *Plugin) Start(s *sdk.PluginSDK) error {
|
||||||
s.SetAutoRestart(true)
|
s.SetAutoRestart(true)
|
||||||
p.sdk = s
|
p.sdk = s
|
||||||
|
p.sessions = make(map[string]*a2aSession)
|
||||||
tp := p.name + "_"
|
tp := p.name + "_"
|
||||||
|
|
||||||
|
// 注册自身为输出通道:agent 回复 emit 到本通道时有落点,
|
||||||
|
// 且 output_list_channels 可见(agent 能主动向 a2a 会话推送消息)。
|
||||||
|
if err := s.RegisterOutputChannel(p.name, 1, "A2A Agent 互联通道(外部 agent 查询的回复由此返回)", sdk.ChannelDef{}, func(args map[string]interface{}) (interface{}, error) {
|
||||||
|
payload, _ := args["payload"].(string)
|
||||||
|
log.Printf("[%s] channel output: %s", p.name, truncateRunes(payload, 120))
|
||||||
|
return map[string]interface{}{"status": "ok"}, nil
|
||||||
|
}); err != nil {
|
||||||
|
log.Printf("[%s] register output channel: %v", p.name, err)
|
||||||
|
}
|
||||||
|
|
||||||
|
// 会话 GC:后台周期回收长期不用的会话
|
||||||
|
go p.sessionGCLoop()
|
||||||
|
|
||||||
s.Settings().RegisterDef(sdk.ConfigDef{
|
s.Settings().RegisterDef(sdk.ConfigDef{
|
||||||
Key: "listen", Default: "127.0.0.1:12000",
|
Key: "listen", Default: "127.0.0.1:12000",
|
||||||
Type: "string", DisplayName: "监听地址",
|
Type: "string", DisplayName: "监听地址",
|
||||||
@ -45,6 +77,7 @@ func (p *Plugin) Start(s *sdk.PluginSDK) error {
|
|||||||
"properties": map[string]interface{}{
|
"properties": map[string]interface{}{
|
||||||
"agent_url": map[string]interface{}{"type": "string", "description": "目标 Agent 的 A2A 端点 URL"},
|
"agent_url": map[string]interface{}{"type": "string", "description": "目标 Agent 的 A2A 端点 URL"},
|
||||||
"query": map[string]interface{}{"type": "string", "description": "发送给目标 Agent 的文本查询"},
|
"query": map[string]interface{}{"type": "string", "description": "发送给目标 Agent 的文本查询"},
|
||||||
|
"session_id": map[string]interface{}{"type": "string", "description": "可选。上次调用返回的 session_id,传入可延续与该 agent 的多轮对话上下文"},
|
||||||
"timeout": map[string]interface{}{"type": "integer", "description": "超时时间(秒),默认 60"},
|
"timeout": map[string]interface{}{"type": "integer", "description": "超时时间(秒),默认 60"},
|
||||||
},
|
},
|
||||||
"required": []string{"agent_url", "query"},
|
"required": []string{"agent_url", "query"},
|
||||||
@ -114,6 +147,66 @@ func (p *Plugin) Stop() error {
|
|||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// sessionGCLoop 周期清理超时会话。
|
||||||
|
func (p *Plugin) sessionGCLoop() {
|
||||||
|
ticker := time.NewTicker(sessionGCPeriod)
|
||||||
|
defer ticker.Stop()
|
||||||
|
for range ticker.C {
|
||||||
|
p.sessMu.Lock()
|
||||||
|
for id, sess := range p.sessions {
|
||||||
|
if time.Since(sess.LastUsed) > 2*time.Hour {
|
||||||
|
delete(p.sessions, id)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
p.sessMu.Unlock()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func truncateRunes(s string, n int) string {
|
||||||
|
r := []rune(s)
|
||||||
|
if len(r) <= n {
|
||||||
|
return s
|
||||||
|
}
|
||||||
|
return string(r[:n]) + "..."
|
||||||
|
}
|
||||||
|
|
||||||
|
// sessionMessages 返回指定会话的近 limit 条消息(时间正序),
|
||||||
|
// 会话不存在返回 nil。消息格式 [{role, text, ts}]。
|
||||||
|
func (p *Plugin) sessionMessages(sessionID string, limit int) []map[string]interface{} {
|
||||||
|
p.sessMu.Lock()
|
||||||
|
sess := p.sessions[sessionID]
|
||||||
|
var hist []string
|
||||||
|
var lastUsed time.Time
|
||||||
|
if sess != nil {
|
||||||
|
hist = append([]string{}, sess.History...)
|
||||||
|
lastUsed = sess.LastUsed
|
||||||
|
}
|
||||||
|
p.sessMu.Unlock()
|
||||||
|
if sess == nil {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
_ = lastUsed
|
||||||
|
// History 交替 [user, agent, user, agent...],取末尾 limit 条,保持时间正序
|
||||||
|
start := 0
|
||||||
|
if len(hist) > limit {
|
||||||
|
start = len(hist) - limit
|
||||||
|
}
|
||||||
|
msgs := make([]map[string]interface{}, 0, len(hist)-start)
|
||||||
|
for i := start; i < len(hist); i++ {
|
||||||
|
role, text := "user", hist[i]
|
||||||
|
if after, ok := strings.CutPrefix(text, "用户: "); ok {
|
||||||
|
role, text = "user", after
|
||||||
|
} else if after, ok := strings.CutPrefix(text, "助手: "); ok {
|
||||||
|
role, text = "agent", after
|
||||||
|
}
|
||||||
|
msgs = append(msgs, map[string]interface{}{
|
||||||
|
"role": role,
|
||||||
|
"text": text,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
return msgs
|
||||||
|
}
|
||||||
|
|
||||||
func (p *Plugin) stopServer() {
|
func (p *Plugin) stopServer() {
|
||||||
p.srvMu.Lock()
|
p.srvMu.Lock()
|
||||||
defer p.srvMu.Unlock()
|
defer p.srvMu.Unlock()
|
||||||
@ -137,7 +230,12 @@ func (p *Plugin) startServer(addr string) error {
|
|||||||
return fmt.Errorf("listen %s: %v", addr, err)
|
return fmt.Errorf("listen %s: %v", addr, err)
|
||||||
}
|
}
|
||||||
|
|
||||||
srv := &http.Server{Handler: mux}
|
srv := &http.Server{
|
||||||
|
Handler: mux,
|
||||||
|
ReadTimeout: 30 * time.Second,
|
||||||
|
WriteTimeout: 120 * time.Second,
|
||||||
|
IdleTimeout: 60 * time.Second,
|
||||||
|
}
|
||||||
addrStr := listener.Addr().String()
|
addrStr := listener.Addr().String()
|
||||||
|
|
||||||
p.srvMu.Lock()
|
p.srvMu.Lock()
|
||||||
@ -186,7 +284,9 @@ func (p *Plugin) handleIncomingA2A(w http.ResponseWriter, r *http.Request) {
|
|||||||
ID string `json:"id"`
|
ID string `json:"id"`
|
||||||
Method string `json:"method"`
|
Method string `json:"method"`
|
||||||
Params struct {
|
Params struct {
|
||||||
Query string `json:"query,omitempty"`
|
Query string `json:"query,omitempty"`
|
||||||
|
SessionID string `json:"session_id,omitempty"`
|
||||||
|
Limit int `json:"limit,omitempty"`
|
||||||
Message *struct {
|
Message *struct {
|
||||||
Role string `json:"role"`
|
Role string `json:"role"`
|
||||||
Parts []struct {
|
Parts []struct {
|
||||||
@ -210,29 +310,96 @@ func (p *Plugin) handleIncomingA2A(w http.ResponseWriter, r *http.Request) {
|
|||||||
}
|
}
|
||||||
queryText = strings.TrimSpace(queryText)
|
queryText = strings.TrimSpace(queryText)
|
||||||
}
|
}
|
||||||
|
if queryText == "" {
|
||||||
// Inject into agent pipeline via interrupt (preempt current processing) or direct input
|
http.Error(w, "query/message.text required", http.StatusBadRequest)
|
||||||
if queryText != "" {
|
return
|
||||||
p.sdk.InjectInterruptText("a2a", "webui", fmt.Sprintf("[来自A2A Agent的查询]\n%s", queryText))
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// Respond with task accepted
|
// 会话:调用方可指定 session_id 延续多轮上下文;不指定则新建。
|
||||||
|
sessionID := strings.TrimSpace(req.Params.SessionID)
|
||||||
|
injectText := queryText
|
||||||
|
p.sessMu.Lock()
|
||||||
|
if sessionID != "" {
|
||||||
|
sess := p.sessions[sessionID]
|
||||||
|
if sess == nil {
|
||||||
|
sess = &a2aSession{ID: sessionID, LastUsed: time.Now()}
|
||||||
|
p.sessions[sessionID] = sess
|
||||||
|
}
|
||||||
|
sess.LastUsed = time.Now()
|
||||||
|
// 有历史则把上下文拼在前面(截尾防爆量)
|
||||||
|
if len(sess.History) > 0 {
|
||||||
|
ctxText := strings.Join(sess.History, "\n")
|
||||||
|
injectText = "[对话上下文]\n" + ctxText + "\n[本轮输入]\n" + queryText
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
sessionID = fmt.Sprintf("a2a_%d", time.Now().UnixNano())
|
||||||
|
p.sessions[sessionID] = &a2aSession{ID: sessionID, LastUsed: time.Now()}
|
||||||
|
}
|
||||||
|
p.sessMu.Unlock()
|
||||||
|
|
||||||
|
// 同步注入:阻塞等待 agent 处理完成拿回复(不再抢占打断、
|
||||||
|
// 也不再回 202 让请求方永远等不到结果)。HTTP 超时由调用方控制。
|
||||||
|
reply := p.sdk.InjectInputSync(p.name, p.name,
|
||||||
|
fmt.Sprintf("[来自A2A Agent的查询 session=%s]\n%s\n[注意] 请直接以文本回复本查询,不要调用 output_send__%s——你的最终文本回复会被系统自动返回给请求方。", sessionID, injectText, p.name))
|
||||||
|
|
||||||
|
// 回复写回会话历史(下一轮作为上下文)
|
||||||
|
p.sessMu.Lock()
|
||||||
|
if sess := p.sessions[sessionID]; sess != nil {
|
||||||
|
sess.History = append(sess.History, "用户: "+queryText, "助手: "+reply)
|
||||||
|
if len(sess.History) > maxSessionTurns*2 {
|
||||||
|
sess.History = sess.History[len(sess.History)-maxSessionTurns*2 :]
|
||||||
|
}
|
||||||
|
sess.LastUsed = time.Now()
|
||||||
|
}
|
||||||
|
p.sessMu.Unlock()
|
||||||
|
|
||||||
resp := map[string]interface{}{
|
resp := map[string]interface{}{
|
||||||
"jsonrpc": "2.0",
|
"jsonrpc": "2.0",
|
||||||
"id": req.ID,
|
"id": req.ID,
|
||||||
"result": map[string]interface{}{
|
"result": map[string]interface{}{
|
||||||
"id": fmt.Sprintf("task_%d", time.Now().UnixNano()),
|
"id": fmt.Sprintf("task_%d", time.Now().UnixNano()),
|
||||||
"status": "submitted",
|
"status": "completed",
|
||||||
|
"session_id": sessionID,
|
||||||
|
"message": map[string]interface{}{
|
||||||
|
"role": "agent",
|
||||||
|
"parts": []map[string]string{{"type": "text", "text": reply}},
|
||||||
|
},
|
||||||
},
|
},
|
||||||
}
|
}
|
||||||
w.Header().Set("Content-Type", "application/json")
|
w.Header().Set("Content-Type", "application/json")
|
||||||
json.NewEncoder(w).Encode(resp)
|
json.NewEncoder(w).Encode(resp)
|
||||||
|
|
||||||
case "tasks.get":
|
case "tasks.get", "session.get":
|
||||||
|
// 按 session_id 返回会话内近 N 条消息(默认 10 条)。
|
||||||
|
sessionID := strings.TrimSpace(req.Params.SessionID)
|
||||||
|
if sessionID == "" {
|
||||||
|
sessionID = strings.TrimSpace(req.Params.Query)
|
||||||
|
}
|
||||||
|
limit := 10
|
||||||
|
if req.Params.Limit > 0 && req.Params.Limit <= 100 {
|
||||||
|
limit = req.Params.Limit
|
||||||
|
}
|
||||||
|
msgs := p.sessionMessages(sessionID, limit)
|
||||||
|
if msgs == nil {
|
||||||
|
w.Header().Set("Content-Type", "application/json")
|
||||||
|
json.NewEncoder(w).Encode(map[string]interface{}{
|
||||||
|
"jsonrpc": "2.0", "id": req.ID,
|
||||||
|
"result": map[string]interface{}{
|
||||||
|
"session_id": sessionID,
|
||||||
|
"status": "not_found",
|
||||||
|
"messages": []interface{}{},
|
||||||
|
},
|
||||||
|
})
|
||||||
|
return
|
||||||
|
}
|
||||||
w.Header().Set("Content-Type", "application/json")
|
w.Header().Set("Content-Type", "application/json")
|
||||||
json.NewEncoder(w).Encode(map[string]interface{}{
|
json.NewEncoder(w).Encode(map[string]interface{}{
|
||||||
"jsonrpc": "2.0", "id": req.ID,
|
"jsonrpc": "2.0", "id": req.ID,
|
||||||
"result": map[string]interface{}{"id": req.Params.Query, "status": "unknown"},
|
"result": map[string]interface{}{
|
||||||
|
"session_id": sessionID,
|
||||||
|
"status": "completed",
|
||||||
|
"messages": msgs,
|
||||||
|
},
|
||||||
})
|
})
|
||||||
|
|
||||||
default:
|
default:
|
||||||
@ -276,9 +443,10 @@ type A2ARequest struct {
|
|||||||
}
|
}
|
||||||
|
|
||||||
type A2AParams struct {
|
type A2AParams struct {
|
||||||
Query string `json:"query,omitempty"`
|
Query string `json:"query,omitempty"`
|
||||||
Message *A2AMessage `json:"message,omitempty"`
|
SessionID string `json:"session_id,omitempty"`
|
||||||
TaskID string `json:"id,omitempty"`
|
Message *A2AMessage `json:"message,omitempty"`
|
||||||
|
TaskID string `json:"id,omitempty"`
|
||||||
}
|
}
|
||||||
|
|
||||||
type A2AResponse struct {
|
type A2AResponse struct {
|
||||||
@ -291,6 +459,7 @@ type A2AResponse struct {
|
|||||||
type A2AResult struct {
|
type A2AResult struct {
|
||||||
TaskID string `json:"id,omitempty"`
|
TaskID string `json:"id,omitempty"`
|
||||||
Status string `json:"status,omitempty"`
|
Status string `json:"status,omitempty"`
|
||||||
|
SessionID string `json:"session_id,omitempty"`
|
||||||
Message *A2AMessage `json:"message,omitempty"`
|
Message *A2AMessage `json:"message,omitempty"`
|
||||||
AgentCard *A2AAgentCard `json:"agent_card,omitempty"`
|
AgentCard *A2AAgentCard `json:"agent_card,omitempty"`
|
||||||
}
|
}
|
||||||
@ -356,6 +525,7 @@ func (p *Plugin) handleA2ADiscover(args map[string]interface{}) (interface{}, er
|
|||||||
func (p *Plugin) handleA2AQuery(args map[string]interface{}) (interface{}, error) {
|
func (p *Plugin) handleA2AQuery(args map[string]interface{}) (interface{}, error) {
|
||||||
agentURL, _ := args["agent_url"].(string)
|
agentURL, _ := args["agent_url"].(string)
|
||||||
query, _ := args["query"].(string)
|
query, _ := args["query"].(string)
|
||||||
|
sessionID, _ := args["session_id"].(string) // 可选:延续对方会话
|
||||||
timeoutSec := 60
|
timeoutSec := 60
|
||||||
if v, ok := args["timeout"].(float64); ok && v > 0 {
|
if v, ok := args["timeout"].(float64); ok && v > 0 {
|
||||||
timeoutSec = int(v)
|
timeoutSec = int(v)
|
||||||
@ -377,7 +547,8 @@ func (p *Plugin) handleA2AQuery(args map[string]interface{}) (interface{}, error
|
|||||||
ID: fmt.Sprintf("a2a_%d", time.Now().UnixNano()),
|
ID: fmt.Sprintf("a2a_%d", time.Now().UnixNano()),
|
||||||
Method: "tasks.send",
|
Method: "tasks.send",
|
||||||
Params: A2AParams{
|
Params: A2AParams{
|
||||||
Message: &A2AMessage{Role: "user", Parts: []A2APart{{Text: query, Type: "text"}}},
|
SessionID: sessionID,
|
||||||
|
Message: &A2AMessage{Role: "user", Parts: []A2APart{{Text: query, Type: "text"}}},
|
||||||
},
|
},
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -416,10 +587,18 @@ func (p *Plugin) handleA2AQuery(args map[string]interface{}) (interface{}, error
|
|||||||
replyText = strings.TrimSpace(replyText)
|
replyText = strings.TrimSpace(replyText)
|
||||||
}
|
}
|
||||||
|
|
||||||
return map[string]interface{}{
|
result := map[string]interface{}{
|
||||||
"task_id": a2aResp.Result.TaskID, "status": a2aResp.Result.Status,
|
"task_id": a2aResp.Result.TaskID, "status": a2aResp.Result.Status,
|
||||||
"response": replyText,
|
"response": replyText,
|
||||||
}, nil
|
}
|
||||||
|
if a2aResp.Result.SessionID != "" || sessionID != "" {
|
||||||
|
result["session_id"] = a2aResp.Result.SessionID
|
||||||
|
if result["session_id"] == "" {
|
||||||
|
result["session_id"] = sessionID
|
||||||
|
}
|
||||||
|
result["note"] = "延续会话:下次调用传此 session_id 可保持上下文"
|
||||||
|
}
|
||||||
|
return result, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
// ---- Management Handlers ----
|
// ---- Management Handlers ----
|
||||||
|
|||||||
@ -2,11 +2,15 @@
|
|||||||
"name": "acp",
|
"name": "acp",
|
||||||
"name_zh": "ACP 代理通信",
|
"name_zh": "ACP 代理通信",
|
||||||
"name_en": "ACP Agent Client Protocol",
|
"name_en": "ACP Agent Client Protocol",
|
||||||
"version": "1.0.0",
|
"version": "1.2.0",
|
||||||
"description": "Agent Client Protocol 通信插件:充当 ACP 服务端接受其他 Agent 的任务请求,同时提供客户端工具向远程 ACP Agent(如 opencode)发起会话并读取回复",
|
"description": "Agent Client Protocol 通信插件:充当 ACP 服务端接受其他 Agent 的任务请求,同时提供客户端工具向远程 ACP Agent(如 opencode)发起会话并读取回复",
|
||||||
"author": "HomeAgent",
|
"author": "HomeAgent",
|
||||||
"entry": "plugin.so",
|
"entry": "plugin.so",
|
||||||
"tags": ["acp", "agent", "interop"],
|
"tags": [
|
||||||
|
"acp",
|
||||||
|
"agent",
|
||||||
|
"interop"
|
||||||
|
],
|
||||||
"targets": "linux/amd64",
|
"targets": "linux/amd64",
|
||||||
"outdir": "dist",
|
"outdir": "dist",
|
||||||
"bundle": true,
|
"bundle": true,
|
||||||
|
|||||||
@ -34,8 +34,13 @@ type Plugin struct {
|
|||||||
type sessionState struct {
|
type sessionState struct {
|
||||||
ID string
|
ID string
|
||||||
Replying []map[string]interface{}
|
Replying []map[string]interface{}
|
||||||
|
History []string // 轮次历史 [user, agent, user, agent...],延续上下文用
|
||||||
|
LastUsed time.Time
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// maxSessionTurns 单会话保留的最大轮次对数。
|
||||||
|
const maxSessionTurns = 10
|
||||||
|
|
||||||
func (p *Plugin) Name() string { return p.name }
|
func (p *Plugin) Name() string { return p.name }
|
||||||
|
|
||||||
func (p *Plugin) Start(s *sdk.PluginSDK) error {
|
func (p *Plugin) Start(s *sdk.PluginSDK) error {
|
||||||
@ -44,6 +49,14 @@ func (p *Plugin) Start(s *sdk.PluginSDK) error {
|
|||||||
p.sessions = make(map[string]*sessionState)
|
p.sessions = make(map[string]*sessionState)
|
||||||
tp := p.name + "_"
|
tp := p.name + "_"
|
||||||
|
|
||||||
|
// 注册自身为输出通道:agent 回复 emit 到本通道时有落点。
|
||||||
|
// (回复主要走同步注入返回,此通道用于 agent 主动 output_send__acp)
|
||||||
|
s.RegisterOutputChannel(p.name, 1, "ACP Agent 互联通道(外部 agent 会话的回复由此返回)", sdk.ChannelDef{}, func(args map[string]interface{}) (interface{}, error) {
|
||||||
|
payload, _ := args["payload"].(string)
|
||||||
|
log.Printf("[%s] channel output: %s", p.name, truncateStr(payload, 120))
|
||||||
|
return map[string]interface{}{"status": "ok"}, nil
|
||||||
|
})
|
||||||
|
|
||||||
s.Settings().RegisterDef(sdk.ConfigDef{
|
s.Settings().RegisterDef(sdk.ConfigDef{
|
||||||
Key: "listen", Default: "127.0.0.1:12001",
|
Key: "listen", Default: "127.0.0.1:12001",
|
||||||
Type: "string", DisplayName: "监听地址",
|
Type: "string", DisplayName: "监听地址",
|
||||||
@ -58,6 +71,7 @@ func (p *Plugin) Start(s *sdk.PluginSDK) error {
|
|||||||
"properties": map[string]interface{}{
|
"properties": map[string]interface{}{
|
||||||
"server_url": map[string]interface{}{"type": "string", "description": "目标 ACP 服务端地址(如 http://127.0.0.1:13000)"},
|
"server_url": map[string]interface{}{"type": "string", "description": "目标 ACP 服务端地址(如 http://127.0.0.1:13000)"},
|
||||||
"prompt": map[string]interface{}{"type": "string", "description": "发送给目标 Agent 的任务描述"},
|
"prompt": map[string]interface{}{"type": "string", "description": "发送给目标 Agent 的任务描述"},
|
||||||
|
"session_id": map[string]interface{}{"type": "string", "description": "可选。上次调用返回的 session_id,传入可延续与该 agent 的多轮对话上下文"},
|
||||||
"timeout": map[string]interface{}{"type": "integer", "description": "等待回复超时(秒),默认 120"},
|
"timeout": map[string]interface{}{"type": "integer", "description": "等待回复超时(秒),默认 120"},
|
||||||
},
|
},
|
||||||
"required": []string{"server_url", "prompt"},
|
"required": []string{"server_url", "prompt"},
|
||||||
@ -171,6 +185,7 @@ func (p *Plugin) handleSessionPost(w http.ResponseWriter, r *http.Request) {
|
|||||||
Text string `json:"text"`
|
Text string `json:"text"`
|
||||||
} `json:"request,omitempty"`
|
} `json:"request,omitempty"`
|
||||||
SessionID string `json:"session_id,omitempty"`
|
SessionID string `json:"session_id,omitempty"`
|
||||||
|
Limit int `json:"limit,omitempty"`
|
||||||
Final bool `json:"final,omitempty"`
|
Final bool `json:"final,omitempty"`
|
||||||
} `json:"params,omitempty"`
|
} `json:"params,omitempty"`
|
||||||
}
|
}
|
||||||
@ -190,21 +205,109 @@ func (p *Plugin) handleSessionPost(w http.ResponseWriter, r *http.Request) {
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
sid := fmt.Sprintf("session_%d", time.Now().UnixNano())
|
// 会话:调用方可指定 session_id 延续多轮;不指定则新建。
|
||||||
|
sid := strings.TrimSpace(req.Params.SessionID)
|
||||||
p.mu.Lock()
|
p.mu.Lock()
|
||||||
p.sessions[sid] = &sessionState{ID: sid}
|
if sid != "" {
|
||||||
|
if _, exists := p.sessions[sid]; !exists {
|
||||||
|
p.sessions[sid] = &sessionState{ID: sid, LastUsed: time.Now()}
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
sid = fmt.Sprintf("session_%d", time.Now().UnixNano())
|
||||||
|
p.sessions[sid] = &sessionState{ID: sid, LastUsed: time.Now()}
|
||||||
|
}
|
||||||
|
st := p.sessions[sid]
|
||||||
p.mu.Unlock()
|
p.mu.Unlock()
|
||||||
|
|
||||||
if p.sdk != nil {
|
// 延续上下文
|
||||||
p.sdk.InjectInterruptText(p.name, "acp",
|
injectText := text
|
||||||
fmt.Sprintf("[来自ACP Agent的请求请求 session %s]\n%s", sid, text))
|
p.mu.Lock()
|
||||||
|
if len(st.History) > 0 {
|
||||||
|
ctxText := strings.Join(st.History, "\n")
|
||||||
|
injectText = "[对话上下文]\n" + ctxText + "\n[本轮输入]\n" + text
|
||||||
}
|
}
|
||||||
|
p.mu.Unlock()
|
||||||
|
|
||||||
|
// 同步注入等待回复:不抢占打断,完整闭环返回文本。
|
||||||
|
reply := ""
|
||||||
|
if p.sdk != nil {
|
||||||
|
reply = p.sdk.InjectInputSync(p.name, p.name,
|
||||||
|
fmt.Sprintf("[来自ACP Agent的请求 session %s]\n%s\n[注意] 请直接以文本回复本请求,不要调用 output_send__%s——你的最终文本回复会被系统自动返回给请求方。", sid, injectText, p.name))
|
||||||
|
}
|
||||||
|
|
||||||
|
// 写回历史 + 填充 Replying 供 SSE 消费
|
||||||
|
p.mu.Lock()
|
||||||
|
st.History = append(st.History, "用户: "+text, "助手: "+reply)
|
||||||
|
if len(st.History) > maxSessionTurns*2 {
|
||||||
|
st.History = st.History[len(st.History)-maxSessionTurns*2:]
|
||||||
|
}
|
||||||
|
st.LastUsed = time.Now()
|
||||||
|
if reply != "" {
|
||||||
|
st.Replying = append(st.Replying, map[string]interface{}{
|
||||||
|
"type": "reply", "text": reply,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
p.mu.Unlock()
|
||||||
|
|
||||||
w.Header().Set("Content-Type", "application/json")
|
w.Header().Set("Content-Type", "application/json")
|
||||||
json.NewEncoder(w).Encode(map[string]interface{}{
|
json.NewEncoder(w).Encode(map[string]interface{}{
|
||||||
"jsonrpc": "2.0", "id": req.ID,
|
"jsonrpc": "2.0", "id": req.ID,
|
||||||
"result": map[string]interface{}{
|
"result": map[string]interface{}{
|
||||||
"session": map[string]interface{}{"id": sid},
|
"session": map[string]interface{}{"id": sid},
|
||||||
|
"reply": reply,
|
||||||
|
},
|
||||||
|
})
|
||||||
|
|
||||||
|
case "session/get":
|
||||||
|
// 按 session_id 返回会话内近 N 条消息(默认 10 条,时间正序)
|
||||||
|
sid := req.Params.SessionID
|
||||||
|
p.mu.RLock()
|
||||||
|
st := p.sessions[sid]
|
||||||
|
var hist []string
|
||||||
|
if st != nil {
|
||||||
|
hist = append([]string{}, st.History...)
|
||||||
|
}
|
||||||
|
p.mu.RUnlock()
|
||||||
|
if st == nil {
|
||||||
|
w.Header().Set("Content-Type", "application/json")
|
||||||
|
json.NewEncoder(w).Encode(map[string]interface{}{
|
||||||
|
"jsonrpc": "2.0", "id": req.ID,
|
||||||
|
"result": map[string]interface{}{
|
||||||
|
"session_id": sid,
|
||||||
|
"status": "not_found",
|
||||||
|
"messages": []interface{}{},
|
||||||
|
},
|
||||||
|
})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
limit := 10
|
||||||
|
if req.Params.Limit > 0 && req.Params.Limit <= 100 {
|
||||||
|
limit = req.Params.Limit
|
||||||
|
}
|
||||||
|
start := 0
|
||||||
|
if len(hist) > limit {
|
||||||
|
start = len(hist) - limit
|
||||||
|
}
|
||||||
|
msgs := make([]map[string]interface{}, 0, len(hist)-start)
|
||||||
|
for i := start; i < len(hist); i++ {
|
||||||
|
role, text := "user", hist[i]
|
||||||
|
if after, ok := strings.CutPrefix(text, "用户: "); ok {
|
||||||
|
role, text = "user", after
|
||||||
|
} else if after, ok := strings.CutPrefix(text, "助手: "); ok {
|
||||||
|
role, text = "agent", after
|
||||||
|
}
|
||||||
|
msgs = append(msgs, map[string]interface{}{
|
||||||
|
"role": role,
|
||||||
|
"text": text,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
w.Header().Set("Content-Type", "application/json")
|
||||||
|
json.NewEncoder(w).Encode(map[string]interface{}{
|
||||||
|
"jsonrpc": "2.0", "id": req.ID,
|
||||||
|
"result": map[string]interface{}{
|
||||||
|
"session_id": sid,
|
||||||
|
"status": "completed",
|
||||||
|
"messages": msgs,
|
||||||
},
|
},
|
||||||
})
|
})
|
||||||
|
|
||||||
@ -212,12 +315,17 @@ func (p *Plugin) handleSessionPost(w http.ResponseWriter, r *http.Request) {
|
|||||||
sid := req.Params.SessionID
|
sid := req.Params.SessionID
|
||||||
p.mu.Lock()
|
p.mu.Lock()
|
||||||
st := p.sessions[sid]
|
st := p.sessions[sid]
|
||||||
if st != nil && req.Params.Final {
|
|
||||||
st.Replying = append(st.Replying, map[string]interface{}{
|
|
||||||
"type": "reply", "text": "done",
|
|
||||||
})
|
|
||||||
}
|
|
||||||
p.mu.Unlock()
|
p.mu.Unlock()
|
||||||
|
if st == nil {
|
||||||
|
http.Error(w, "session not found", http.StatusNotFound)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if req.Params.Final {
|
||||||
|
// 客户端结束会话:标记并保留历史(后续可再 session/new 续)
|
||||||
|
p.mu.Lock()
|
||||||
|
st.LastUsed = time.Now()
|
||||||
|
p.mu.Unlock()
|
||||||
|
}
|
||||||
|
|
||||||
w.Header().Set("Content-Type", "application/json")
|
w.Header().Set("Content-Type", "application/json")
|
||||||
json.NewEncoder(w).Encode(map[string]interface{}{
|
json.NewEncoder(w).Encode(map[string]interface{}{
|
||||||
@ -338,6 +446,7 @@ func (p *Plugin) handleAcpQuery(args map[string]interface{}) (interface{}, error
|
|||||||
if prompt == "" {
|
if prompt == "" {
|
||||||
return map[string]interface{}{"error": "prompt 不能为空"}, nil
|
return map[string]interface{}{"error": "prompt 不能为空"}, nil
|
||||||
}
|
}
|
||||||
|
sessionID, _ := args["session_id"].(string) // 可选:延续对方会话
|
||||||
timeoutSec := 120
|
timeoutSec := 120
|
||||||
if v, ok := args["timeout"].(float64); ok && v > 0 {
|
if v, ok := args["timeout"].(float64); ok && v > 0 {
|
||||||
timeoutSec = int(v)
|
timeoutSec = int(v)
|
||||||
@ -346,12 +455,16 @@ func (p *Plugin) handleAcpQuery(args map[string]interface{}) (interface{}, error
|
|||||||
endpoint := serverURL + "/api/session"
|
endpoint := serverURL + "/api/session"
|
||||||
client := &http.Client{Timeout: time.Duration(timeoutSec) * time.Second}
|
client := &http.Client{Timeout: time.Duration(timeoutSec) * time.Second}
|
||||||
|
|
||||||
|
params := map[string]interface{}{
|
||||||
|
"request": map[string]interface{}{"text": prompt},
|
||||||
|
}
|
||||||
|
if sessionID != "" {
|
||||||
|
params["session_id"] = sessionID
|
||||||
|
}
|
||||||
newBody, _ := json.Marshal(map[string]interface{}{
|
newBody, _ := json.Marshal(map[string]interface{}{
|
||||||
"jsonrpc": "2.0", "id": "acp-" + fmt.Sprintf("%d", time.Now().UnixNano()),
|
"jsonrpc": "2.0", "id": "acp-" + fmt.Sprintf("%d", time.Now().UnixNano()),
|
||||||
"method": "session/new",
|
"method": "session/new",
|
||||||
"params": map[string]interface{}{
|
"params": params,
|
||||||
"request": map[string]interface{}{"text": prompt},
|
|
||||||
},
|
|
||||||
})
|
})
|
||||||
|
|
||||||
req, _ := http.NewRequest("POST", endpoint, bytes.NewReader(newBody))
|
req, _ := http.NewRequest("POST", endpoint, bytes.NewReader(newBody))
|
||||||
@ -416,6 +529,7 @@ func (p *Plugin) handleAcpQuery(args map[string]interface{}) (interface{}, error
|
|||||||
"session_id": sid,
|
"session_id": sid,
|
||||||
"status": "completed",
|
"status": "completed",
|
||||||
"reply": replyText,
|
"reply": replyText,
|
||||||
|
"note": "延续会话:下次调用传此 session_id 可保持上下文",
|
||||||
}, nil
|
}, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@ -2,14 +2,19 @@
|
|||||||
"name": "ai_image",
|
"name": "ai_image",
|
||||||
"name_zh": "AI绘图",
|
"name_zh": "AI绘图",
|
||||||
"name_en": "AI Image",
|
"name_en": "AI Image",
|
||||||
"version": "1.0.0",
|
"version": "1.3.0",
|
||||||
"description": "AI 图像生成插件,支持 OpenAI DALL·E / Stable Diffusion",
|
"description": "AI 图像生成插件,支持 OpenAI DALL·E / Stable Diffusion",
|
||||||
"author": "HomeAgent",
|
"author": "HomeAgent",
|
||||||
"entry": "plugin.so",
|
"entry": "plugin.so",
|
||||||
"tags": ["ai", "image", "draw", "generate"],
|
"tags": [
|
||||||
|
"ai",
|
||||||
|
"image",
|
||||||
|
"draw",
|
||||||
|
"generate"
|
||||||
|
],
|
||||||
"targets": "linux/amd64",
|
"targets": "linux/amd64",
|
||||||
"outdir": "dist",
|
"outdir": "dist",
|
||||||
"bundle": true,
|
"bundle": true,
|
||||||
"replaces": {},
|
"replaces": {},
|
||||||
"source_dirs": []
|
"source_dirs": []
|
||||||
}
|
}
|
||||||
@ -5,7 +5,10 @@ import (
|
|||||||
"encoding/json"
|
"encoding/json"
|
||||||
"fmt"
|
"fmt"
|
||||||
"io"
|
"io"
|
||||||
|
"log"
|
||||||
"net/http"
|
"net/http"
|
||||||
|
"os"
|
||||||
|
"path/filepath"
|
||||||
"strconv"
|
"strconv"
|
||||||
"strings"
|
"strings"
|
||||||
"time"
|
"time"
|
||||||
@ -21,6 +24,8 @@ type Plugin struct {
|
|||||||
provider string
|
provider string
|
||||||
model string
|
model string
|
||||||
size string
|
size string
|
||||||
|
baseURL string
|
||||||
|
dataDir string // <data>/ai_images:生成本地图片存放目录
|
||||||
}
|
}
|
||||||
|
|
||||||
func NewPluginFactory(name string, config map[string]interface{}) (sdk.Plugin, error) {
|
func NewPluginFactory(name string, config map[string]interface{}) (sdk.Plugin, error) {
|
||||||
@ -111,10 +116,15 @@ func (p *Plugin) Start(s *sdk.PluginSDK) error {
|
|||||||
DisplayName: "API Key", Description: "OpenAI / Stable Diffusion API Key",
|
DisplayName: "API Key", Description: "OpenAI / Stable Diffusion API Key",
|
||||||
Category: "ai_image", Secret: true,
|
Category: "ai_image", Secret: true,
|
||||||
})
|
})
|
||||||
|
s.Settings().RegisterDef(sdk.ConfigDef{
|
||||||
|
Key: "base_url", Default: "", Type: "string",
|
||||||
|
DisplayName: "Base URL", Description: "自定义 OpenAI 兼容网关地址(不带 /v1 尾缀,如 http://127.0.0.1:8081);为空走官方 https://api.openai.com",
|
||||||
|
Category: "ai_image",
|
||||||
|
})
|
||||||
s.Settings().RegisterDef(sdk.ConfigDef{
|
s.Settings().RegisterDef(sdk.ConfigDef{
|
||||||
Key: "provider", Default: "openai", Type: "string",
|
Key: "provider", Default: "openai", Type: "string",
|
||||||
DisplayName: "Provider", Description: "Image generation provider: openai / stability",
|
DisplayName: "Provider", Description: "Image generation provider: openai / stability",
|
||||||
Category: "ai_image",
|
Category: "ai_image",
|
||||||
})
|
})
|
||||||
s.Settings().RegisterDef(sdk.ConfigDef{
|
s.Settings().RegisterDef(sdk.ConfigDef{
|
||||||
Key: "model", Default: "dall-e-3", Type: "string",
|
Key: "model", Default: "dall-e-3", Type: "string",
|
||||||
@ -131,10 +141,23 @@ func (p *Plugin) Start(s *sdk.PluginSDK) error {
|
|||||||
p.provider = getSetting(s.Settings(), "provider", "openai")
|
p.provider = getSetting(s.Settings(), "provider", "openai")
|
||||||
p.model = getSetting(s.Settings(), "model", "dall-e-3")
|
p.model = getSetting(s.Settings(), "model", "dall-e-3")
|
||||||
p.size = getSetting(s.Settings(), "size", "1024x1024")
|
p.size = getSetting(s.Settings(), "size", "1024x1024")
|
||||||
|
p.baseURL = strings.TrimRight(strings.TrimSpace(getSetting(s.Settings(), "base_url", "")), "/")
|
||||||
|
|
||||||
|
// 生图本地存放目录:插件专属数据目录(SDK DataDir API,内核保证存在)。
|
||||||
|
if p.sdk != nil {
|
||||||
|
if dd := s.Settings().DataDir(); dd != "" {
|
||||||
|
p.dataDir = dd
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if p.dataDir == "" {
|
||||||
|
// 旧版内核无 DataDir API 时退到 /tmp
|
||||||
|
p.dataDir = filepath.Join(os.TempDir(), "homeagent_ai_images")
|
||||||
|
}
|
||||||
|
os.MkdirAll(p.dataDir, 0755)
|
||||||
|
|
||||||
tp := p.name + "_"
|
tp := p.name + "_"
|
||||||
s.RegisterTool(tp+"generate", sdk.ToolDef{
|
s.RegisterTool(tp+"generate", sdk.ToolDef{
|
||||||
Name: tp + "generate", Description: "Generate image from text prompt using AI. Returns image URL.",
|
Name: tp + "generate", Description: "Generate image from text prompt using AI. Downloads the result locally and returns a local file path (permanent, no expiry). To show the user, send it via output_send with type=image and payload=the returned path.",
|
||||||
Parameters: map[string]interface{}{
|
Parameters: map[string]interface{}{
|
||||||
"type": "object",
|
"type": "object",
|
||||||
"properties": map[string]interface{}{
|
"properties": map[string]interface{}{
|
||||||
@ -209,6 +232,14 @@ func (p *Plugin) handleGenerate(args map[string]interface{}) (interface{}, error
|
|||||||
}
|
}
|
||||||
|
|
||||||
func (p *Plugin) generateOpenAI(prompt, model, size string, n int, apiKey string) (interface{}, error) {
|
func (p *Plugin) generateOpenAI(prompt, model, size string, n int, apiKey string) (interface{}, error) {
|
||||||
|
// 上游地址:base_url 非空时走自定义网关(如本机 llmsproxy),约定不带 /v1 尾缀;
|
||||||
|
// 为空保持官方直连。兼容误配了 /v1 尾缀的情况(去重)。
|
||||||
|
endpoint := "https://api.openai.com/v1/images/generations"
|
||||||
|
if p.baseURL != "" {
|
||||||
|
base := strings.TrimSuffix(p.baseURL, "/v1")
|
||||||
|
endpoint = base + "/v1/images/generations"
|
||||||
|
}
|
||||||
|
|
||||||
body := openAIReq{
|
body := openAIReq{
|
||||||
Model: model,
|
Model: model,
|
||||||
Prompt: prompt,
|
Prompt: prompt,
|
||||||
@ -217,8 +248,9 @@ func (p *Plugin) generateOpenAI(prompt, model, size string, n int, apiKey string
|
|||||||
ResponseFormat: "url",
|
ResponseFormat: "url",
|
||||||
}
|
}
|
||||||
|
|
||||||
|
log.Printf("[ai_image] endpoint=%s baseURL=%q model=%q", endpoint, p.baseURL, model)
|
||||||
b, _ := json.Marshal(body)
|
b, _ := json.Marshal(body)
|
||||||
req, _ := http.NewRequest("POST", "https://api.openai.com/v1/images/generations", bytes.NewReader(b))
|
req, _ := http.NewRequest("POST", endpoint, bytes.NewReader(b))
|
||||||
req.Header.Set("Content-Type", "application/json")
|
req.Header.Set("Content-Type", "application/json")
|
||||||
req.Header.Set("Authorization", "Bearer "+apiKey)
|
req.Header.Set("Authorization", "Bearer "+apiKey)
|
||||||
|
|
||||||
@ -247,14 +279,74 @@ func (p *Plugin) generateOpenAI(prompt, model, size string, n int, apiKey string
|
|||||||
urls[i] = d.URL
|
urls[i] = d.URL
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// 下载到本地 data 目录,返回本地文件路径(而非临时 S3 URL):
|
||||||
|
// - S3 临时 URL 约 1 小时过期,且对无浏览器 UA 的客户端拒绝访问
|
||||||
|
// - 本地路径可经 webui /files/ 永久下发给所有客户端(含 API key 客户端)
|
||||||
|
localPaths := make([]string, len(urls))
|
||||||
|
var errs []string
|
||||||
|
for i, u := range urls {
|
||||||
|
path, err := p.downloadImage(u, fmt.Sprintf("ai_%s_%d", model, time.Now().UnixNano()))
|
||||||
|
if err != nil {
|
||||||
|
errs = append(errs, fmt.Sprintf("第%d张下载失败: %v", i+1, err))
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
localPaths[i] = path
|
||||||
|
}
|
||||||
|
|
||||||
|
content := fmt.Sprintf("Generated %d image(s) with model %s:", len(urls), model)
|
||||||
|
for _, pth := range localPaths {
|
||||||
|
if pth != "" {
|
||||||
|
content += "\n" + pth
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if len(errs) > 0 {
|
||||||
|
content += "\n\n" + strings.Join(errs, "\n")
|
||||||
|
}
|
||||||
|
content += "\n\n已将图片保存到本地(不会过期)。如需展示请用 output_send__webui(payload=本地路径, type=image)。"
|
||||||
return map[string]interface{}{
|
return map[string]interface{}{
|
||||||
"content": fmt.Sprintf("Generated %d image(s) with model %s:\n%s", len(urls), model, strings.Join(urls, "\n")),
|
"content": content,
|
||||||
"images": urls,
|
"images": localPaths,
|
||||||
"prompt": prompt,
|
"prompt": prompt,
|
||||||
"model": model,
|
"model": model,
|
||||||
|
"local_paths": localPaths,
|
||||||
}, nil
|
}, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// downloadImage 把生图返回的临时 URL 下载为本地文件,返回本地路径。
|
||||||
|
// 带浏览器 UA 以规避图床对无 UA 客户端的拦截。
|
||||||
|
func (p *Plugin) downloadImage(url, baseName string) (string, error) {
|
||||||
|
dl := &http.Client{Timeout: 60 * time.Second}
|
||||||
|
req, err := http.NewRequest("GET", url, nil)
|
||||||
|
if err != nil {
|
||||||
|
return "", err
|
||||||
|
}
|
||||||
|
req.Header.Set("User-Agent", "Mozilla/5.0 (compatible; HomeAgent/1.0)")
|
||||||
|
resp, err := dl.Do(req)
|
||||||
|
if err != nil {
|
||||||
|
return "", err
|
||||||
|
}
|
||||||
|
defer resp.Body.Close()
|
||||||
|
if resp.StatusCode != http.StatusOK {
|
||||||
|
b, _ := io.ReadAll(resp.Body)
|
||||||
|
return "", fmt.Errorf("HTTP %d: %s", resp.StatusCode, strings.TrimSpace(string(b))[:200])
|
||||||
|
}
|
||||||
|
data, err := io.ReadAll(resp.Body)
|
||||||
|
if err != nil {
|
||||||
|
return "", err
|
||||||
|
}
|
||||||
|
ext := ".png"
|
||||||
|
if ct := resp.Header.Get("Content-Type"); strings.Contains(ct, "jpeg") || strings.Contains(ct, "jpg") {
|
||||||
|
ext = ".jpg"
|
||||||
|
} else if strings.Contains(ct, "webp") {
|
||||||
|
ext = ".webp"
|
||||||
|
}
|
||||||
|
path := filepath.Join(p.dataDir, baseName+ext)
|
||||||
|
if err := os.WriteFile(path, data, 0644); err != nil {
|
||||||
|
return "", err
|
||||||
|
}
|
||||||
|
return path, nil
|
||||||
|
}
|
||||||
|
|
||||||
type stabilityReq struct {
|
type stabilityReq struct {
|
||||||
TextPrompts []stabilityPrompt `json:"text_prompts"`
|
TextPrompts []stabilityPrompt `json:"text_prompts"`
|
||||||
Width int `json:"width"`
|
Width int `json:"width"`
|
||||||
@ -334,7 +426,7 @@ func (p *Plugin) generateStability(prompt, model, size string, n int, apiKey str
|
|||||||
}
|
}
|
||||||
|
|
||||||
return map[string]interface{}{
|
return map[string]interface{}{
|
||||||
"content": fmt.Sprintf("Generated %d image(s) via Stability AI:\n%s", len(urls), strings.Join(urls, "\n")),
|
"content": fmt.Sprintf("Generated %d image(s) via Stability AI:\n%s\n\n图片已保存到本地,如需展示请用 output_send(type=image)。", len(urls), strings.Join(urls, "\n")),
|
||||||
"images": urls,
|
"images": urls,
|
||||||
"prompt": prompt,
|
"prompt": prompt,
|
||||||
"model": model,
|
"model": model,
|
||||||
|
|||||||
@ -2,14 +2,18 @@
|
|||||||
"name": "bili",
|
"name": "bili",
|
||||||
"name_zh": "B站视频下载",
|
"name_zh": "B站视频下载",
|
||||||
"name_en": "Bilibili Video Downloader",
|
"name_en": "Bilibili Video Downloader",
|
||||||
"version": "1.1.0",
|
"version": "1.2.0",
|
||||||
"description": "B站视频下载工具,基于 yt-dlp 引擎。支持查看视频清晰度列表、指定格式下载、可配置下载目录。",
|
"description": "B站视频下载工具,基于 yt-dlp 引擎。支持查看视频清晰度列表、指定格式下载、可配置下载目录。",
|
||||||
"author": "HomeAgent",
|
"author": "HomeAgent",
|
||||||
"entry": "plugin.so",
|
"entry": "plugin.so",
|
||||||
"tags": ["bili", "video", "download"],
|
"tags": [
|
||||||
|
"bili",
|
||||||
|
"video",
|
||||||
|
"download"
|
||||||
|
],
|
||||||
"targets": "linux/amd64",
|
"targets": "linux/amd64",
|
||||||
"outdir": "dist",
|
"outdir": "dist",
|
||||||
"bundle": true,
|
"bundle": true,
|
||||||
"replaces": {},
|
"replaces": {},
|
||||||
"source_dirs": []
|
"source_dirs": []
|
||||||
}
|
}
|
||||||
@ -107,6 +107,14 @@ func (p *Plugin) handleBiliVideo(args map[string]interface{}) (interface{}, erro
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
// 安全校验:output_dir 是配置项,但避免被配成系统目录导致 yt-dlp 任意位置写。
|
||||||
|
// 禁止根/家目录本身,且规范化后必须落在明确子目录内。
|
||||||
|
outputDir = filepath.Clean(outputDir)
|
||||||
|
for _, forbidden := range []string{"/", "/etc", "/usr", "/bin", "/sbin", "/boot", "/dev", "/proc", "/sys", "/var"} {
|
||||||
|
if outputDir == forbidden {
|
||||||
|
return nil, fmt.Errorf("output_dir 不能是系统目录 %s", forbidden)
|
||||||
|
}
|
||||||
|
}
|
||||||
os.MkdirAll(outputDir, 0755)
|
os.MkdirAll(outputDir, 0755)
|
||||||
|
|
||||||
var out bytes.Buffer
|
var out bytes.Buffer
|
||||||
|
|||||||
@ -2,14 +2,20 @@
|
|||||||
"name": "browser",
|
"name": "browser",
|
||||||
"name_zh": "浏览器",
|
"name_zh": "浏览器",
|
||||||
"name_en": "Browser",
|
"name_en": "Browser",
|
||||||
"version": "2.0.0",
|
"version": "2.3.0",
|
||||||
"description": "统一浏览器插件:搜索、HTTP抓取(quick)、无头渲染(normal)、交互式浏览器(interactive/CDP)",
|
"description": "统一浏览器插件:搜索、HTTP抓取(quick)、无头渲染(normal)、交互式浏览器(interactive/CDP)",
|
||||||
"author": "HomeAgent",
|
"author": "HomeAgent",
|
||||||
"entry": "plugin.so",
|
"entry": "plugin.so",
|
||||||
"tags": ["web", "search", "fetch", "browser", "cdp"],
|
"tags": [
|
||||||
|
"web",
|
||||||
|
"search",
|
||||||
|
"fetch",
|
||||||
|
"browser",
|
||||||
|
"cdp"
|
||||||
|
],
|
||||||
"targets": "linux/amd64",
|
"targets": "linux/amd64",
|
||||||
"outdir": "dist",
|
"outdir": "dist",
|
||||||
"bundle": true,
|
"bundle": true,
|
||||||
"replaces": {},
|
"replaces": {},
|
||||||
"source_dirs": []
|
"source_dirs": []
|
||||||
}
|
}
|
||||||
@ -13,6 +13,7 @@ import (
|
|||||||
"net/url"
|
"net/url"
|
||||||
"os"
|
"os"
|
||||||
"os/exec"
|
"os/exec"
|
||||||
|
"path/filepath"
|
||||||
"regexp"
|
"regexp"
|
||||||
"strconv"
|
"strconv"
|
||||||
"strings"
|
"strings"
|
||||||
@ -33,23 +34,49 @@ type Plugin struct {
|
|||||||
proxy string
|
proxy string
|
||||||
client *http.Client
|
client *http.Client
|
||||||
|
|
||||||
sessions map[string]*BrowserSession
|
sessions map[string]*BrowserSession
|
||||||
nextID int
|
nextID int
|
||||||
wg sync.WaitGroup
|
wg sync.WaitGroup
|
||||||
stopCh chan struct{}
|
stopCh chan struct{}
|
||||||
stopOnce sync.Once
|
stopOnce sync.Once
|
||||||
|
profilesDir string // 持久化 profile 根目录(<data>/browser_profiles),空则禁用
|
||||||
|
|
||||||
|
// 共享浏览器单例:所有 agent 共用一个 Chromium 进程(全局 UserDataDir,
|
||||||
|
// 登录态/cookies 跨 agent、跨会话、跨插件重启保留),每个 start 创建一个
|
||||||
|
// 新标签页(CDP Target)。同 source 复用自己的标签页。浏览器进程在
|
||||||
|
// 最后一个标签页关闭后保留(避免反复冷启动),仅插件 Stop 时回收。
|
||||||
|
sharedAllocCtx context.Context
|
||||||
|
sharedAllocCancel context.CancelFunc
|
||||||
|
sharedMu sync.Mutex
|
||||||
}
|
}
|
||||||
|
|
||||||
type BrowserSession struct {
|
type BrowserSession struct {
|
||||||
id string
|
id string
|
||||||
allocCtx context.Context
|
allocCtx context.Context // 共享浏览器进程上下文(shared=true 时指向全局单例)
|
||||||
cancel context.CancelFunc
|
cancel context.CancelFunc
|
||||||
ctx context.Context
|
ctx context.Context // 本会话的 Target 上下文(一个标签页)
|
||||||
createdAt time.Time
|
createdAt time.Time
|
||||||
timeout time.Duration
|
timeout time.Duration
|
||||||
closed bool
|
closed bool
|
||||||
mu sync.Mutex
|
mu sync.Mutex
|
||||||
currentURL string
|
currentURL string
|
||||||
|
shared bool // true=共享浏览器的一个标签页;false=独占浏览器实例
|
||||||
|
profileDir string // 非空表示使用持久化 profile(关闭时不删目录)
|
||||||
|
sessionKey string // 共享模式下的复用键(agent 来源标识,同 key 复用同一标签页)
|
||||||
|
}
|
||||||
|
|
||||||
|
// sanitizeProfileName 消毒 profile 名:仅保留字母数字-_,防路径穿越。
|
||||||
|
func sanitizeProfileName(name string) string {
|
||||||
|
var b []byte
|
||||||
|
for _, c := range []byte(name) {
|
||||||
|
if (c >= 'a' && c <= 'z') || (c >= 'A' && c <= 'Z') || (c >= '0' && c <= '9') || c == '-' || c == '_' {
|
||||||
|
b = append(b, c)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if len(b) == 0 || string(b) == "." || string(b) == ".." {
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
return string(b)
|
||||||
}
|
}
|
||||||
|
|
||||||
func NewPluginFactory(name string, config map[string]interface{}) (sdk.Plugin, error) {
|
func NewPluginFactory(name string, config map[string]interface{}) (sdk.Plugin, error) {
|
||||||
@ -187,6 +214,13 @@ func (p *Plugin) Start(s *sdk.PluginSDK) error {
|
|||||||
p.proxy = readCfg(s.Settings(), "proxy", "")
|
p.proxy = readCfg(s.Settings(), "proxy", "")
|
||||||
p.client = newHTTPClient(p.timeout, p.proxy)
|
p.client = newHTTPClient(p.timeout, p.proxy)
|
||||||
|
|
||||||
|
// 持久化 profile 根目录:<data>/browser_profiles
|
||||||
|
if dd, err := s.Settings().GetCore("daemon.data_dir"); err == nil {
|
||||||
|
if s2, ok := dd.(string); ok && s2 != "" {
|
||||||
|
p.profilesDir = filepath.Join(s2, "browser_profiles")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
tp := p.name + "_"
|
tp := p.name + "_"
|
||||||
|
|
||||||
cleaner := func(output string) string {
|
cleaner := func(output string) string {
|
||||||
@ -242,12 +276,13 @@ func (p *Plugin) Start(s *sdk.PluginSDK) error {
|
|||||||
|
|
||||||
s.RegisterTool(tp+"start", sdk.ToolDef{
|
s.RegisterTool(tp+"start", sdk.ToolDef{
|
||||||
Name: tp + "start",
|
Name: tp + "start",
|
||||||
Description: "启动交互式浏览器会话(interactive 模式)。通过 CDP 连接 Chromium,支持导航、截图、点击、输入等操作。返回会话 ID。",
|
Description: "启动交互式浏览器会话。优先连接 systemd 托管的共享浏览器后端(登录态全机共享、各 agent 独立标签页);后端未安装时返回 need_install 引导(调 browser_install);无法安装时自动降级本地临时模式。同来源复用已有标签页。",
|
||||||
Parameters: map[string]interface{}{
|
Parameters: map[string]interface{}{
|
||||||
"type": "object",
|
"type": "object",
|
||||||
"properties": map[string]interface{}{
|
"properties": map[string]interface{}{
|
||||||
"url": map[string]interface{}{"type": "string", "description": "初始导航 URL(可选)"},
|
"url": map[string]interface{}{"type": "string", "description": "初始导航 URL(可选)"},
|
||||||
"timeout": map[string]interface{}{"type": "string", "description": "会话超时(如 5m, 10m,默认 10m)"},
|
"timeout": map[string]interface{}{"type": "string", "description": "会话超时(如 5m, 10m,默认 10m)"},
|
||||||
|
"profile": map[string]interface{}{"type": "string", "description": "持久化档案名(可选,如 main)。同名档案共享登录态与浏览历史;不指定则为一次性临时会话"},
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
}, p.handleBrowserStart)
|
}, p.handleBrowserStart)
|
||||||
@ -337,6 +372,15 @@ func (p *Plugin) Start(s *sdk.PluginSDK) error {
|
|||||||
},
|
},
|
||||||
}, p.handleScroll)
|
}, p.handleScroll)
|
||||||
|
|
||||||
|
s.RegisterTool(tp+"install", sdk.ToolDef{
|
||||||
|
Name: tp + "install",
|
||||||
|
Description: "安装并启动共享浏览器后端(homeagent-browser.service,systemd 托管)。前提:本机已有 chromium 二进制(无则先提示用户安装:apt install chromium 或等价命令)。安装后所有 agent 共享同一浏览器实例与登录态。",
|
||||||
|
Parameters: map[string]interface{}{
|
||||||
|
"type": "object",
|
||||||
|
"properties": map[string]interface{}{},
|
||||||
|
},
|
||||||
|
}, p.handleBrowserInstall)
|
||||||
|
|
||||||
s.RegisterTool(tp+"close", sdk.ToolDef{
|
s.RegisterTool(tp+"close", sdk.ToolDef{
|
||||||
Name: tp + "close",
|
Name: tp + "close",
|
||||||
Description: "关闭交互式浏览器会话,释放资源。",
|
Description: "关闭交互式浏览器会话,释放资源。",
|
||||||
@ -663,6 +707,9 @@ func (p *Plugin) fetchWithChromium(rawURL string, maxChars int) (interface{}, er
|
|||||||
}, nil
|
}, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// handleRender 无头渲染 JS 页面并提取文本(normal 模式)。
|
||||||
|
// 主路径走共享浏览器后端:开临时标签页(带全机登录态)→ 渲染 → 取 text → 关标签页;
|
||||||
|
// 后端不可用时 failback 到独立 chromium --dump-dom(无登录态,仅保功能)。
|
||||||
func (p *Plugin) handleRender(args map[string]interface{}) (interface{}, error) {
|
func (p *Plugin) handleRender(args map[string]interface{}) (interface{}, error) {
|
||||||
rawURL := readArg(args, "url", "")
|
rawURL := readArg(args, "url", "")
|
||||||
if rawURL == "" {
|
if rawURL == "" {
|
||||||
@ -672,32 +719,70 @@ func (p *Plugin) handleRender(args map[string]interface{}) (interface{}, error)
|
|||||||
return errResult(err.Error()), nil
|
return errResult(err.Error()), nil
|
||||||
}
|
}
|
||||||
waitSec := int64(readArg(args, "wait", float64(0)))
|
waitSec := int64(readArg(args, "wait", float64(0)))
|
||||||
if waitSec > 0 {
|
|
||||||
time.Sleep(time.Duration(waitSec) * time.Second)
|
var title, html string
|
||||||
|
rendered := false
|
||||||
|
|
||||||
|
ok, needInstall, _ := p.ensureBackend()
|
||||||
|
if ok {
|
||||||
|
remoteCtx, remoteCancel := chromedp.NewRemoteAllocator(context.Background(), cdpEndpoint)
|
||||||
|
defer remoteCancel()
|
||||||
|
tabCtx, tabCancel := chromedp.NewContext(remoteCtx)
|
||||||
|
defer tabCancel()
|
||||||
|
actions := []chromedp.Action{
|
||||||
|
chromedp.Navigate(rawURL),
|
||||||
|
chromedp.WaitReady("body"),
|
||||||
|
}
|
||||||
|
if waitSec > 0 {
|
||||||
|
actions = append(actions, chromedp.Sleep(time.Duration(waitSec)*time.Second))
|
||||||
|
}
|
||||||
|
actions = append(actions,
|
||||||
|
chromedp.Title(&title),
|
||||||
|
chromedp.OuterHTML("html", &html),
|
||||||
|
)
|
||||||
|
// 整体限时 30s,防慢页拖死工具
|
||||||
|
rctx, rcancel := context.WithTimeout(tabCtx, 30*time.Second)
|
||||||
|
defer rcancel()
|
||||||
|
if err := chromedp.Run(rctx, actions...); err == nil {
|
||||||
|
rendered = true
|
||||||
|
} else {
|
||||||
|
log.Printf("[%s] render via backend failed (%v), fallback to dump-dom", p.name, err)
|
||||||
|
}
|
||||||
|
} else if needInstall {
|
||||||
|
return map[string]interface{}{
|
||||||
|
"error": "browser backend not installed",
|
||||||
|
"need_install": true,
|
||||||
|
"guide": "调用 browser_install 安装共享后端;或重试本工具自动降级为独立 chromium 渲染(不带登录态)",
|
||||||
|
}, nil
|
||||||
}
|
}
|
||||||
var html string
|
|
||||||
chromiumPath := "/usr/local/bin/chromium"
|
if !rendered {
|
||||||
if _, err := os.Stat(chromiumPath); err == nil {
|
chromiumPath := "/usr/local/bin/chromium"
|
||||||
|
if _, err := os.Stat(chromiumPath); err != nil {
|
||||||
|
if _, e2 := exec.LookPath("chromium"); e2 == nil {
|
||||||
|
chromiumPath = "chromium"
|
||||||
|
} else {
|
||||||
|
return errResult("no chromium available"), nil
|
||||||
|
}
|
||||||
|
}
|
||||||
var out bytes.Buffer
|
var out bytes.Buffer
|
||||||
cmd := exec.Command(chromiumPath, "--headless", "--disable-gpu", "--no-sandbox", "--dump-dom", rawURL)
|
cmd := exec.Command(chromiumPath, "--headless", "--disable-gpu", "--no-sandbox", "--dump-dom", rawURL)
|
||||||
cmd.Stdout = &out
|
cmd.Stdout = &out
|
||||||
if err := cmd.Run(); err != nil {
|
done := make(chan error, 1)
|
||||||
return errResult("chromium: " + err.Error()), nil
|
go func() { done <- cmd.Run() }()
|
||||||
|
select {
|
||||||
|
case err := <-done:
|
||||||
|
if err != nil {
|
||||||
|
return errResult("chromium: " + err.Error()), nil
|
||||||
|
}
|
||||||
|
case <-time.After(30 * time.Second):
|
||||||
|
cmd.Process.Kill()
|
||||||
|
<-done // 回收子进程避免僵尸
|
||||||
|
return errResult("chromium dump-dom timeout (30s)"), nil
|
||||||
}
|
}
|
||||||
html = out.String()
|
html = out.String()
|
||||||
} else {
|
|
||||||
resp, err := http.Get(rawURL)
|
|
||||||
if err != nil {
|
|
||||||
return errResult("http get: " + err.Error()), nil
|
|
||||||
}
|
|
||||||
defer resp.Body.Close()
|
|
||||||
body, _ := io.ReadAll(resp.Body)
|
|
||||||
html = string(body)
|
|
||||||
}
|
|
||||||
title := ""
|
|
||||||
if m := regexp.MustCompile(`<title>([^<]+)</title>`).FindStringSubmatch(html); len(m) > 1 {
|
|
||||||
title = m[1]
|
|
||||||
}
|
}
|
||||||
|
|
||||||
text := htmlToText(html)
|
text := htmlToText(html)
|
||||||
origLen := len(text)
|
origLen := len(text)
|
||||||
truncated := origLen > 5000
|
truncated := origLen > 5000
|
||||||
@ -712,18 +797,71 @@ func (p *Plugin) handleRender(args map[string]interface{}) (interface{}, error)
|
|||||||
if truncated {
|
if truncated {
|
||||||
result += fmt.Sprintf("\n\n...(仅显示前 5000 字符,共 %d 字符)", origLen)
|
result += fmt.Sprintf("\n\n...(仅显示前 5000 字符,共 %d 字符)", origLen)
|
||||||
}
|
}
|
||||||
return map[string]interface{}{"content": result, "title": title}, nil
|
mode := "backend-tab"
|
||||||
|
if !rendered {
|
||||||
|
mode = "local-dump-dom"
|
||||||
|
}
|
||||||
|
return map[string]interface{}{"content": result, "title": title, "mode": mode}, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
// ── Interactive Browser Session (CDP) ─────────────────────
|
func cdpReachable(endpoint string) bool {
|
||||||
|
client := &http.Client{Timeout: 2 * time.Second}
|
||||||
func (p *Plugin) handleBrowserStart(args map[string]interface{}) (interface{}, error) {
|
resp, err := client.Get(endpoint + "/json/version")
|
||||||
timeoutStr := readArg(args, "timeout", "10m")
|
|
||||||
timeout, err := time.ParseDuration(timeoutStr)
|
|
||||||
if err != nil {
|
if err != nil {
|
||||||
timeout = 10 * time.Minute
|
return false
|
||||||
}
|
}
|
||||||
|
resp.Body.Close()
|
||||||
|
return resp.StatusCode == http.StatusOK
|
||||||
|
}
|
||||||
|
|
||||||
|
// systemdUnitActive 检查 homeagent-browser.service 是否已安装。
|
||||||
|
func systemdUnitInstalled() bool {
|
||||||
|
out, err := exec.Command("systemctl", "cat", "homeagent-browser.service").CombinedOutput()
|
||||||
|
return err == nil && len(out) > 0
|
||||||
|
}
|
||||||
|
|
||||||
|
// startSystemdUnit 尝试 systemctl start(单元已安装但未运行时用)。
|
||||||
|
func startSystemdUnit() error {
|
||||||
|
return exec.Command("systemctl", "start", "homeagent-browser.service").Run()
|
||||||
|
}
|
||||||
|
|
||||||
|
// cdpEndpoint 是共享 Chromium 后端的 CDP 地址(homeagent-browser.service)。
|
||||||
|
const cdpEndpoint = "http://127.0.0.1:9222"
|
||||||
|
|
||||||
|
// ensureBackend 确保共享浏览器后端可用:探测 → 拉起已装服务 → 报告未装。
|
||||||
|
// 返回 (ok, needInstall, err)。
|
||||||
|
func (p *Plugin) ensureBackend() (bool, bool, error) {
|
||||||
|
if cdpReachable(cdpEndpoint) {
|
||||||
|
return true, false, nil
|
||||||
|
}
|
||||||
|
if systemdUnitInstalled() {
|
||||||
|
if err := startSystemdUnit(); err == nil {
|
||||||
|
// 等待 CDP 就绪(chromium 启动 ~1-3s)
|
||||||
|
for i := 0; i < 10; i++ {
|
||||||
|
time.Sleep(500 * time.Millisecond)
|
||||||
|
if cdpReachable(cdpEndpoint) {
|
||||||
|
return true, false, nil
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return false, false, fmt.Errorf("browser backend service installed but failed to start")
|
||||||
|
}
|
||||||
|
return false, true, nil // 未安装
|
||||||
|
}
|
||||||
|
|
||||||
|
// sharedTab 在共享后端上开一个新标签页(RemoteAllocator + NewContext)。
|
||||||
|
func sharedTab(allocCtx context.Context) (context.Context, context.CancelFunc, error) {
|
||||||
|
tabCtx, tabCancel := chromedp.NewContext(allocCtx)
|
||||||
|
if err := chromedp.Run(tabCtx); err != nil {
|
||||||
|
tabCancel()
|
||||||
|
return nil, nil, err
|
||||||
|
}
|
||||||
|
return tabCtx, tabCancel, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// localSpawnFailback 本地拉起一次性 Chromium(离线机器无法装 systemd 服务的兜底)。
|
||||||
|
// 用临时 profile,登录态不跨会话保留——仅保证功能可用。
|
||||||
|
func (p *Plugin) localSpawnFailback() (context.Context, context.CancelFunc, context.CancelFunc, error) {
|
||||||
opts := append(chromedp.DefaultExecAllocatorOptions[:],
|
opts := append(chromedp.DefaultExecAllocatorOptions[:],
|
||||||
chromedp.Flag("headless", true),
|
chromedp.Flag("headless", true),
|
||||||
chromedp.Flag("disable-gpu", true),
|
chromedp.Flag("disable-gpu", true),
|
||||||
@ -733,23 +871,83 @@ func (p *Plugin) handleBrowserStart(args map[string]interface{}) (interface{}, e
|
|||||||
if p.proxy != "" {
|
if p.proxy != "" {
|
||||||
opts = append(opts, chromedp.Flag("proxy-server", p.proxy))
|
opts = append(opts, chromedp.Flag("proxy-server", p.proxy))
|
||||||
}
|
}
|
||||||
|
allocCtx, cancelAlloc := chromedp.NewExecAllocator(context.Background(), opts...)
|
||||||
allocCtx, cancel := chromedp.NewExecAllocator(context.Background(), opts...)
|
|
||||||
ctx, _ := chromedp.NewContext(allocCtx)
|
ctx, _ := chromedp.NewContext(allocCtx)
|
||||||
|
|
||||||
// 立即分配浏览器和 Target,确保后续 Run 的 timeout context 不会杀死浏览器进程
|
|
||||||
// chromedp 官方警告:首调用带 timeout 的 Run 会杀死整个浏览器
|
|
||||||
if err := chromedp.Run(ctx); err != nil {
|
if err := chromedp.Run(ctx); err != nil {
|
||||||
cancel()
|
cancelAlloc()
|
||||||
return errResult("browser init failed: " + err.Error()), nil
|
return nil, nil, nil, err
|
||||||
|
}
|
||||||
|
return allocCtx, cancelAlloc, nil, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (p *Plugin) handleBrowserStart(args map[string]interface{}) (interface{}, error) {
|
||||||
|
timeoutStr := readArg(args, "timeout", "10m")
|
||||||
|
timeout, err := time.ParseDuration(timeoutStr)
|
||||||
|
if err != nil {
|
||||||
|
timeout = 10 * time.Minute
|
||||||
}
|
}
|
||||||
|
|
||||||
session := &BrowserSession{
|
source := readArg(args, "source", "")
|
||||||
allocCtx: allocCtx,
|
if source == "" {
|
||||||
cancel: cancel,
|
source = "default"
|
||||||
ctx: ctx,
|
}
|
||||||
createdAt: time.Now(),
|
|
||||||
timeout: timeout,
|
// 同 source 复用已有标签页
|
||||||
|
p.mu.Lock()
|
||||||
|
for _, s := range p.sessions {
|
||||||
|
if s.shared && s.sessionKey == source && !s.closed {
|
||||||
|
s.mu.Lock()
|
||||||
|
id := s.id
|
||||||
|
cur := s.currentURL
|
||||||
|
s.mu.Unlock()
|
||||||
|
p.mu.Unlock()
|
||||||
|
return map[string]interface{}{
|
||||||
|
"id": id,
|
||||||
|
"status": "reused",
|
||||||
|
"url": cur,
|
||||||
|
"note": "已复用本来源的现有标签页(登录态全机共享)",
|
||||||
|
}, nil
|
||||||
|
}
|
||||||
|
}
|
||||||
|
p.mu.Unlock()
|
||||||
|
|
||||||
|
var session *BrowserSession
|
||||||
|
|
||||||
|
// 路径一:systemd 托管的共享后端(主路径)
|
||||||
|
ok, needInstall, berr := p.ensureBackend()
|
||||||
|
if ok {
|
||||||
|
remoteCtx, remoteCancel := chromedp.NewRemoteAllocator(context.Background(), cdpEndpoint)
|
||||||
|
probe, _ := chromedp.NewContext(remoteCtx)
|
||||||
|
if err := chromedp.Run(probe); err != nil {
|
||||||
|
remoteCancel()
|
||||||
|
return errResult("connect to browser backend failed: " + err.Error()), nil
|
||||||
|
}
|
||||||
|
tabCtx, tabCancel := chromedp.NewContext(remoteCtx)
|
||||||
|
if err := chromedp.Run(tabCtx); err != nil {
|
||||||
|
remoteCancel()
|
||||||
|
return errResult("open tab failed: " + err.Error()), nil
|
||||||
|
}
|
||||||
|
session = &BrowserSession{
|
||||||
|
allocCtx: remoteCtx,
|
||||||
|
cancel: tabCancel,
|
||||||
|
ctx: tabCtx,
|
||||||
|
createdAt: time.Now(),
|
||||||
|
timeout: timeout,
|
||||||
|
shared: true,
|
||||||
|
sessionKey: source,
|
||||||
|
}
|
||||||
|
} else if needInstall {
|
||||||
|
guide := "浏览器后端未安装。请确认后调用 browser_install 工具完成安装:" +
|
||||||
|
"需要本机有 chromium 二进制(apt install chromium 或等价命令)," +
|
||||||
|
"插件会注册 homeagent-browser.service 并启动。" +
|
||||||
|
"若本机无法联网安装 chromium,可继续用本地临时模式(重试 browser_start 即自动降级)。"
|
||||||
|
return map[string]interface{}{
|
||||||
|
"error": "backend not installed",
|
||||||
|
"need_install": true,
|
||||||
|
"guide": guide,
|
||||||
|
}, nil
|
||||||
|
} else {
|
||||||
|
return errResult("browser backend error: " + berr.Error()), nil
|
||||||
}
|
}
|
||||||
|
|
||||||
p.mu.Lock()
|
p.mu.Lock()
|
||||||
@ -761,7 +959,7 @@ func (p *Plugin) handleBrowserStart(args map[string]interface{}) (interface{}, e
|
|||||||
|
|
||||||
initURL := readArg(args, "url", "")
|
initURL := readArg(args, "url", "")
|
||||||
if initURL != "" {
|
if initURL != "" {
|
||||||
if err := chromedp.Run(ctx,
|
if err := chromedp.Run(session.ctx,
|
||||||
chromedp.Navigate(initURL),
|
chromedp.Navigate(initURL),
|
||||||
chromedp.WaitReady("body"),
|
chromedp.WaitReady("body"),
|
||||||
); err != nil {
|
); err != nil {
|
||||||
@ -772,13 +970,13 @@ func (p *Plugin) handleBrowserStart(args map[string]interface{}) (interface{}, e
|
|||||||
return errResult("navigate failed: " + err.Error()), nil
|
return errResult("navigate failed: " + err.Error()), nil
|
||||||
}
|
}
|
||||||
session.currentURL = initURL
|
session.currentURL = initURL
|
||||||
p.sdk.InjectTextNoMemory(p.name, p.name, fmt.Sprintf("[浏览器 %s 已打开 %s]", id, initURL))
|
|
||||||
}
|
}
|
||||||
|
|
||||||
log.Printf("[%s] created browser session %s: url=%s timeout=%v", p.name, id, initURL, timeout)
|
log.Printf("[%s] created browser session %s: url=%s timeout=%v source=%s", p.name, id, initURL, timeout, source)
|
||||||
return map[string]interface{}{
|
return map[string]interface{}{
|
||||||
"id": id,
|
"id": id,
|
||||||
"status": "created",
|
"status": "created",
|
||||||
|
"mode": "shared-backend",
|
||||||
"url": initURL,
|
"url": initURL,
|
||||||
"timeout": timeout.String(),
|
"timeout": timeout.String(),
|
||||||
}, nil
|
}, nil
|
||||||
@ -1019,3 +1217,104 @@ func (p *Plugin) cleanupLoop() {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ── browser_install:安装 systemd 托管的共享浏览器后端 ──────────
|
||||||
|
|
||||||
|
// handleBrowserInstall 注册 homeagent-browser.service 并启动,验证 CDP 可达。
|
||||||
|
// 返回给 agent 的结果含全机共享使用指南(由 agent 转述给用户)。
|
||||||
|
func (p *Plugin) handleBrowserInstall(args map[string]interface{}) (interface{}, error) {
|
||||||
|
if cdpReachable(cdpEndpoint) {
|
||||||
|
return map[string]interface{}{"status": "already_running", "endpoint": cdpEndpoint}, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// 探测 chromium 二进制
|
||||||
|
chromePath := ""
|
||||||
|
for _, c := range []string{
|
||||||
|
"/usr/bin/chromium", "/usr/bin/chromium-browser",
|
||||||
|
"/usr/local/bin/chromium", "/usr/bin/google-chrome",
|
||||||
|
} {
|
||||||
|
if _, err := os.Stat(c); err == nil {
|
||||||
|
chromePath = c
|
||||||
|
break
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if out, err := exec.LookPath("chromium"); err == nil && chromePath == "" {
|
||||||
|
chromePath = out
|
||||||
|
} else if out, err := exec.LookPath("google-chrome"); err == nil && chromePath == "" {
|
||||||
|
chromePath = out
|
||||||
|
}
|
||||||
|
if chromePath == "" {
|
||||||
|
return map[string]interface{}{
|
||||||
|
"error": "chromium binary not found",
|
||||||
|
"hint": "请先安装 chromium:apt install chromium 或等价命令,然后重试 browser_install",
|
||||||
|
}, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
profileDir := ""
|
||||||
|
if p.profilesDir != "" {
|
||||||
|
profileDir = filepath.Join(p.profilesDir, "shared")
|
||||||
|
os.MkdirAll(profileDir, 0755)
|
||||||
|
} else {
|
||||||
|
// profilesDir 未注入(无 data_dir),退到 /var/lib/homeagent-browser
|
||||||
|
profileDir = "/var/lib/homeagent-browser"
|
||||||
|
os.MkdirAll(profileDir, 0755)
|
||||||
|
}
|
||||||
|
|
||||||
|
unit := fmt.Sprintf(`[Unit]
|
||||||
|
Description=HomeAgent Shared Browser Backend (headless chromium, CDP :9222)
|
||||||
|
After=network.target
|
||||||
|
|
||||||
|
[Service]
|
||||||
|
Type=simple
|
||||||
|
ExecStart=%s --headless --no-sandbox --disable-gpu --disable-dev-shm-usage --remote-debugging-port=9222 --user-data-dir=%s --window-size=1280,800 about:blank
|
||||||
|
Restart=always
|
||||||
|
RestartSec=3
|
||||||
|
|
||||||
|
[Install]
|
||||||
|
WantedBy=multi-user.target
|
||||||
|
`, chromePath, profileDir)
|
||||||
|
|
||||||
|
unitPath := "/etc/systemd/system/homeagent-browser.service"
|
||||||
|
if err := os.WriteFile(unitPath, []byte(unit), 0644); err != nil {
|
||||||
|
return map[string]interface{}{
|
||||||
|
"error": "write unit failed (need root): " + err.Error(),
|
||||||
|
"hint": "插件进程无权限写 /etc/systemd/system 时,请让用户手动执行安装命令(见 manual_cmds)",
|
||||||
|
"manual_cmds": []string{
|
||||||
|
"sudo tee /etc/systemd/system/homeagent-browser.service <<'EOF'\n" + unit + "EOF",
|
||||||
|
"sudo systemctl daemon-reload",
|
||||||
|
"sudo systemctl enable --now homeagent-browser.service",
|
||||||
|
},
|
||||||
|
}, nil
|
||||||
|
}
|
||||||
|
for _, cmd := range [][]string{
|
||||||
|
{"systemctl", "daemon-reload"},
|
||||||
|
{"systemctl", "enable", "--now", "homeagent-browser.service"},
|
||||||
|
} {
|
||||||
|
if out, err := exec.Command(cmd[0], cmd[1:]...).CombinedOutput(); err != nil {
|
||||||
|
return map[string]interface{}{
|
||||||
|
"error": fmt.Sprintf("%v: %s", cmd, string(out)),
|
||||||
|
}, nil
|
||||||
|
}
|
||||||
|
}
|
||||||
|
// 等待 CDP 就绪
|
||||||
|
for i := 0; i < 20; i++ {
|
||||||
|
time.Sleep(500 * time.Millisecond)
|
||||||
|
if cdpReachable(cdpEndpoint) {
|
||||||
|
guide := "共享浏览器后端已就绪(CDP " + cdpEndpoint + ")。\n" +
|
||||||
|
"全机共享说明:本机所有 agent(HomeAgent、pi、opencode、deepseekharness 等)都可连接此实例:" +
|
||||||
|
"登录一次全机可用;各 agent 各自占用独立标签页互不干扰;\n" +
|
||||||
|
"- HomeAgent 内部:browser_start 即自动连接本后端\n" +
|
||||||
|
"- 其他 agent:让其浏览器工具/MCP 连接 CDP 端点 " + cdpEndpoint + "(如 playwright connectOverCDP / puppeteer connect)\n" +
|
||||||
|
"- 服务由 systemd 托管:崩溃自动重启,登录态持久保存在 " + profileDir
|
||||||
|
log.Printf("[%s] browser backend installed and running (chrome=%s profile=%s)", p.name, chromePath, profileDir)
|
||||||
|
return map[string]interface{}{
|
||||||
|
"status": "installed",
|
||||||
|
"endpoint": cdpEndpoint,
|
||||||
|
"chrome": chromePath,
|
||||||
|
"profile": profileDir,
|
||||||
|
"guide": guide,
|
||||||
|
}, nil
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return map[string]interface{}{"error": "service started but CDP not reachable after 10s"}, nil
|
||||||
|
}
|
||||||
|
|||||||
@ -2,14 +2,19 @@
|
|||||||
"name": "calendar",
|
"name": "calendar",
|
||||||
"name_zh": "日历",
|
"name_zh": "日历",
|
||||||
"name_en": "Calendar",
|
"name_en": "Calendar",
|
||||||
"version": "1.0.0",
|
"version": "1.1.0",
|
||||||
"description": "日历事件管理,支持提醒和重复事件",
|
"description": "日历事件管理,支持提醒和重复事件",
|
||||||
"author": "HomeAgent",
|
"author": "HomeAgent",
|
||||||
"entry": "plugin.so",
|
"entry": "plugin.so",
|
||||||
"tags": ["calendar", "event", "reminder", "schedule"],
|
"tags": [
|
||||||
|
"calendar",
|
||||||
|
"event",
|
||||||
|
"reminder",
|
||||||
|
"schedule"
|
||||||
|
],
|
||||||
"targets": "linux/amd64",
|
"targets": "linux/amd64",
|
||||||
"outdir": "dist",
|
"outdir": "dist",
|
||||||
"bundle": true,
|
"bundle": true,
|
||||||
"replaces": {},
|
"replaces": {},
|
||||||
"source_dirs": []
|
"source_dirs": []
|
||||||
}
|
}
|
||||||
@ -656,7 +656,7 @@ func (p *Plugin) saveEventsLocked() {
|
|||||||
NextEventID: p.nextEventID,
|
NextEventID: p.nextEventID,
|
||||||
}
|
}
|
||||||
b, _ := json.MarshalIndent(data, "", " ")
|
b, _ := json.MarshalIndent(data, "", " ")
|
||||||
os.WriteFile(p.eventsFile(), b, 0644)
|
atomicWriteJSON(p.eventsFile(), b)
|
||||||
}
|
}
|
||||||
|
|
||||||
// --- Helper: parse remind_before ---
|
// --- Helper: parse remind_before ---
|
||||||
@ -1177,3 +1177,12 @@ func (p *Plugin) handleSearch(args map[string]interface{}) (interface{}, error)
|
|||||||
}
|
}
|
||||||
return map[string]interface{}{"content": strings.Join(lines, "\n")}, nil
|
return map[string]interface{}{"content": strings.Join(lines, "\n")}, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// atomicWriteJSON 原子写 JSON:先写临时文件再 rename,避免进程崩溃截断数据文件。
|
||||||
|
func atomicWriteJSON(path string, data []byte) error {
|
||||||
|
tmp := path + ".tmp"
|
||||||
|
if err := os.WriteFile(tmp, data, 0644); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
return os.Rename(tmp, path)
|
||||||
|
}
|
||||||
|
|||||||
@ -76,9 +76,13 @@ function plugin.start(sdk)
|
|||||||
return nil
|
return nil
|
||||||
end, "own_tools")
|
end, "own_tools")
|
||||||
|
|
||||||
-- 阶段钩子:全局作用域
|
-- 阶段钩子:全局作用域(修改 ctx 字段会写回内核,见 applyLuaStageResult)
|
||||||
sdk.register_stage("pre_action", function(ctx)
|
sdk.register_stage("pre_action", function(ctx)
|
||||||
sdk.log("info", "luademo stage pre_action: user=" .. tostring(ctx.user_id))
|
sdk.log("info", "luademo stage pre_action: user=" .. tostring(ctx.user_id))
|
||||||
|
-- 演示 stage 写回:给 llm_text 追加标记(内核会同步回 StageContext)
|
||||||
|
if ctx.llm_text then
|
||||||
|
ctx.llm_text = ctx.llm_text .. "[luademo]"
|
||||||
|
end
|
||||||
return nil
|
return nil
|
||||||
end)
|
end)
|
||||||
|
|
||||||
|
|||||||
@ -2,14 +2,18 @@
|
|||||||
"name": "memo",
|
"name": "memo",
|
||||||
"name_zh": "备忘录",
|
"name_zh": "备忘录",
|
||||||
"name_en": "Memo",
|
"name_en": "Memo",
|
||||||
"version": "1.0.0",
|
"version": "1.1.0",
|
||||||
"description": "待办与备忘录插件。待办(todo_add/todo_complete/todo_list)会主动提醒;备忘录(memo_create/memo_list/memo_delete)纯记事不提醒。",
|
"description": "待办与备忘录插件。待办(todo_add/todo_complete/todo_list)会主动提醒;备忘录(memo_create/memo_list/memo_delete)纯记事不提醒。",
|
||||||
"author": "HomeAgent",
|
"author": "HomeAgent",
|
||||||
"entry": "plugin.so",
|
"entry": "plugin.so",
|
||||||
"tags": ["memo", "todo", "notes"],
|
"tags": [
|
||||||
|
"memo",
|
||||||
|
"todo",
|
||||||
|
"notes"
|
||||||
|
],
|
||||||
"targets": "linux/amd64",
|
"targets": "linux/amd64",
|
||||||
"outdir": "dist",
|
"outdir": "dist",
|
||||||
"bundle": true,
|
"bundle": true,
|
||||||
"replaces": {},
|
"replaces": {},
|
||||||
"source_dirs": []
|
"source_dirs": []
|
||||||
}
|
}
|
||||||
@ -224,7 +224,7 @@ func (p *Plugin) saveTodos() {
|
|||||||
"next_id": p.nextTID,
|
"next_id": p.nextTID,
|
||||||
}, "", " ")
|
}, "", " ")
|
||||||
p.mu.RUnlock()
|
p.mu.RUnlock()
|
||||||
os.WriteFile(p.todoPath, data, 0644)
|
atomicWriteJSON(p.todoPath, data)
|
||||||
}
|
}
|
||||||
|
|
||||||
func (p *Plugin) saveMemos() {
|
func (p *Plugin) saveMemos() {
|
||||||
@ -234,7 +234,7 @@ func (p *Plugin) saveMemos() {
|
|||||||
"next_id": p.nextMID,
|
"next_id": p.nextMID,
|
||||||
}, "", " ")
|
}, "", " ")
|
||||||
p.mu.RUnlock()
|
p.mu.RUnlock()
|
||||||
os.WriteFile(p.memoPath, data, 0644)
|
atomicWriteJSON(p.memoPath, data)
|
||||||
}
|
}
|
||||||
|
|
||||||
// ── 待办:未完成计数与提醒 ──
|
// ── 待办:未完成计数与提醒 ──
|
||||||
@ -500,3 +500,12 @@ func (p *Plugin) cleanupData() {
|
|||||||
os.Remove(p.memoPath)
|
os.Remove(p.memoPath)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// atomicWriteJSON 原子写 JSON:先写临时文件再 rename,避免进程崩溃截断数据文件。
|
||||||
|
func atomicWriteJSON(path string, data []byte) error {
|
||||||
|
tmp := path + ".tmp"
|
||||||
|
if err := os.WriteFile(tmp, data, 0644); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
return os.Rename(tmp, path)
|
||||||
|
}
|
||||||
|
|||||||
@ -2,14 +2,17 @@
|
|||||||
"name": "qq",
|
"name": "qq",
|
||||||
"name_zh": "QQ消息",
|
"name_zh": "QQ消息",
|
||||||
"name_en": "qq",
|
"name_en": "qq",
|
||||||
"version": "1.0.0",
|
"version": "1.1.0",
|
||||||
"description": "QQ 消息收发插件,通过 NapCat 协议桥接",
|
"description": "QQ 消息收发插件,通过 NapCat 协议桥接",
|
||||||
"author": "HomeAgent",
|
"author": "HomeAgent",
|
||||||
"entry": "plugin.so",
|
"entry": "plugin.so",
|
||||||
"tags": ["qq", "messaging"],
|
"tags": [
|
||||||
|
"qq",
|
||||||
|
"messaging"
|
||||||
|
],
|
||||||
"targets": "linux/amd64",
|
"targets": "linux/amd64",
|
||||||
"outdir": "dist",
|
"outdir": "dist",
|
||||||
"bundle": false,
|
"bundle": false,
|
||||||
"replaces": {},
|
"replaces": {},
|
||||||
"source_dirs": []
|
"source_dirs": []
|
||||||
}
|
}
|
||||||
@ -739,6 +739,11 @@ func (p *Plugin) handleWebhook(w http.ResponseWriter, r *http.Request) {
|
|||||||
w.WriteHeader(http.StatusOK)
|
w.WriteHeader(http.StatusOK)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
// 群消息到达即记录(用于排查 napcat→webhook 链路漏报/丢弃)
|
||||||
|
if evt.MessageType == "group" {
|
||||||
|
log.Printf("[qq] webhook recv group msg id=%d from=%d in=%d raw=%.100s",
|
||||||
|
evt.MessageID, evt.UserID, evt.GroupID, evt.RawMessage)
|
||||||
|
}
|
||||||
|
|
||||||
rawCQ := evt.RawMessage
|
rawCQ := evt.RawMessage
|
||||||
text := rawCQ
|
text := rawCQ
|
||||||
@ -763,6 +768,7 @@ func (p *Plugin) handleWebhook(w http.ResponseWriter, r *http.Request) {
|
|||||||
}
|
}
|
||||||
if evt.MessageType == "group" {
|
if evt.MessageType == "group" {
|
||||||
if !p.isGroupAllowed(evt.GroupID) {
|
if !p.isGroupAllowed(evt.GroupID) {
|
||||||
|
log.Printf("[qq] group msg from %d rejected: policy=%s", evt.GroupID, p.groupPolicy)
|
||||||
w.WriteHeader(http.StatusOK)
|
w.WriteHeader(http.StatusOK)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
@ -773,6 +779,9 @@ func (p *Plugin) handleWebhook(w http.ResponseWriter, r *http.Request) {
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
if !p.isAtBot(evt.Message) {
|
if !p.isAtBot(evt.Message) {
|
||||||
|
// 诊断:@ 解析失败时打印 at 段原文与 botID,定位漏报问题
|
||||||
|
log.Printf("[qq] group msg from %d/%d not @bot (botID=%d, raw=%.120s)",
|
||||||
|
evt.GroupID, evt.UserID, p.botID, rawCQ)
|
||||||
w.WriteHeader(http.StatusOK)
|
w.WriteHeader(http.StatusOK)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
@ -810,6 +819,7 @@ func (p *Plugin) handleWebhook(w http.ResponseWriter, r *http.Request) {
|
|||||||
if evt.GroupID == rule.GroupID {
|
if evt.GroupID == rule.GroupID {
|
||||||
mcMsg := fmt.Sprintf("%s 说 %s", nickname, text)
|
mcMsg := fmt.Sprintf("%s 说 %s", nickname, text)
|
||||||
go func(r ForwardRule, msg string) {
|
go func(r ForwardRule, msg string) {
|
||||||
|
defer func() { _ = recover() }()
|
||||||
if err := rconSend(r.Host, r.Port, r.Password, "say "+msg); err != nil {
|
if err := rconSend(r.Host, r.Port, r.Password, "say "+msg); err != nil {
|
||||||
log.Printf("[qq] rcon forward to %s:%d: %v", r.Host, r.Port, err)
|
log.Printf("[qq] rcon forward to %s:%d: %v", r.Host, r.Port, err)
|
||||||
}
|
}
|
||||||
@ -980,6 +990,7 @@ func (p *Plugin) handleGetMessage(args map[string]interface{}) (interface{}, err
|
|||||||
|
|
||||||
// 异步标记已读
|
// 异步标记已读
|
||||||
go func() {
|
go func() {
|
||||||
|
defer func() { _ = recover() }() // 后台任务不允许 panic 冒泡带崩进程
|
||||||
if d.MessageType == "group" && d.GroupID > 0 {
|
if d.MessageType == "group" && d.GroupID > 0 {
|
||||||
p.napcat("mark_group_msg_as_read", map[string]interface{}{"group_id": d.GroupID})
|
p.napcat("mark_group_msg_as_read", map[string]interface{}{"group_id": d.GroupID})
|
||||||
} else if d.UserID > 0 {
|
} else if d.UserID > 0 {
|
||||||
@ -1070,20 +1081,32 @@ func (p *Plugin) handleChannelOutput(args map[string]interface{}) (interface{},
|
|||||||
}
|
}
|
||||||
return p.napcat("send_private_msg", msg)
|
return p.napcat("send_private_msg", msg)
|
||||||
|
|
||||||
case "image":
|
case "image", "file":
|
||||||
msg := map[string]interface{}{"message": fmt.Sprintf("[CQ:image,file=%s]", payload)}
|
// 收敛到 output 通道:payload 支持本地路径或 http(s) URL。
|
||||||
if groupID != 0 {
|
// 本地路径拷入 NapCat 共享目录转 file:// URI(与 voice 分支同模式),
|
||||||
msg["group_id"] = groupID
|
// 此后 agent 发本地文件不再需要单独的 upload_group_file 工具。
|
||||||
} else {
|
uri := payload
|
||||||
msg["user_id"] = userID
|
if !strings.HasPrefix(payload, "http://") && !strings.HasPrefix(payload, "https://") &&
|
||||||
|
!strings.HasPrefix(payload, "file://") {
|
||||||
|
if _, err := os.Stat(payload); err != nil {
|
||||||
|
return nil, fmt.Errorf("%s 文件不存在: %s", rawType, payload)
|
||||||
|
}
|
||||||
|
os.MkdirAll(p.remoteDir, 0755)
|
||||||
|
dest := filepath.Join(p.remoteDir, sanitizeFilename(filepath.Base(payload)))
|
||||||
|
data, err := os.ReadFile(payload)
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("读取文件失败: %w", err)
|
||||||
|
}
|
||||||
|
if err := os.WriteFile(dest, data, 0644); err != nil {
|
||||||
|
return nil, fmt.Errorf("写入共享目录失败: %w", err)
|
||||||
|
}
|
||||||
|
uri = "file:///app/files/" + filepath.Base(dest)
|
||||||
}
|
}
|
||||||
if groupID != 0 {
|
cqTag := "file"
|
||||||
return p.napcat("send_group_msg", msg)
|
if rawType == "image" {
|
||||||
|
cqTag = "image"
|
||||||
}
|
}
|
||||||
return p.napcat("send_private_msg", msg)
|
msg := map[string]interface{}{"message": fmt.Sprintf("[CQ:%s,file=%s]", cqTag, uri)}
|
||||||
|
|
||||||
case "file":
|
|
||||||
msg := map[string]interface{}{"message": fmt.Sprintf("[CQ:file,file=%s]", payload)}
|
|
||||||
if groupID != 0 {
|
if groupID != 0 {
|
||||||
msg["group_id"] = groupID
|
msg["group_id"] = groupID
|
||||||
} else {
|
} else {
|
||||||
@ -1638,7 +1661,8 @@ func (p *Plugin) handleGetGroupFiles(args map[string]interface{}) (interface{},
|
|||||||
return resp, nil
|
return resp, nil
|
||||||
}
|
}
|
||||||
dlURL := parsed.Data.URL
|
dlURL := parsed.Data.URL
|
||||||
httpResp, err := http.Get(dlURL)
|
client := &http.Client{Timeout: 120 * time.Second}
|
||||||
|
httpResp, err := client.Get(dlURL)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, fmt.Errorf("download: %w", err)
|
return nil, fmt.Errorf("download: %w", err)
|
||||||
}
|
}
|
||||||
@ -1693,6 +1717,11 @@ func (p *Plugin) handleDownloadFile(args map[string]interface{}) (interface{}, e
|
|||||||
task := p.addDownloadTask(fileID, filename)
|
task := p.addDownloadTask(fileID, filename)
|
||||||
|
|
||||||
go func(t *DownloadTask, fid, fname, furl string, gid, uid int64) {
|
go func(t *DownloadTask, fid, fname, furl string, gid, uid int64) {
|
||||||
|
defer func() {
|
||||||
|
if r := recover(); r != nil {
|
||||||
|
log.Printf("[qq] download task %s panic: %v", fid, r)
|
||||||
|
}
|
||||||
|
}()
|
||||||
savePath := ""
|
savePath := ""
|
||||||
errMsg := ""
|
errMsg := ""
|
||||||
if furl != "" {
|
if furl != "" {
|
||||||
|
|||||||
153
example/recoverydiag/diag_test.go
Normal file
153
example/recoverydiag/diag_test.go
Normal file
@ -0,0 +1,153 @@
|
|||||||
|
package main
|
||||||
|
|
||||||
|
import (
|
||||||
|
"encoding/json"
|
||||||
|
"os"
|
||||||
|
"path/filepath"
|
||||||
|
"strings"
|
||||||
|
"testing"
|
||||||
|
)
|
||||||
|
|
||||||
|
var realCfg = "/home/newqqagent/config.db"
|
||||||
|
var realLog = "/home/newqqagent/log"
|
||||||
|
|
||||||
|
func TestDiagTriage(t *testing.T) {
|
||||||
|
p := &Plugin{name: "recoverydiag"}
|
||||||
|
|
||||||
|
cases := []struct {
|
||||||
|
name string
|
||||||
|
args map[string]interface{}
|
||||||
|
want string
|
||||||
|
}{
|
||||||
|
{"signal", map[string]interface{}{"exit_code": 0, "signal": "SIGSEGV"}, "process_death"},
|
||||||
|
{"oom", map[string]interface{}{"exit_code": 0, "signal": "SIGKILL", "crash_reason": "oom-kill"}, "process_starvation"},
|
||||||
|
{"nonzero", map[string]interface{}{"exit_code": 1}, "process_death"},
|
||||||
|
{"healthy", map[string]interface{}{"exit_code": 0}, "normal_stop"},
|
||||||
|
{"alive", map[string]interface{}{"still_alive": true, "signal": "SIGKILL"}, "config_unreachable"},
|
||||||
|
}
|
||||||
|
for _, c := range cases {
|
||||||
|
r, _ := p.handleTriage(c.args)
|
||||||
|
m, ok := r.(map[string]interface{})
|
||||||
|
if !ok {
|
||||||
|
t.Fatalf("%s: not a map", c.name)
|
||||||
|
}
|
||||||
|
if got, _ := m["class"].(string); got != c.want {
|
||||||
|
t.Errorf("%s: class = %q, want %q", c.name, got, c.want)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestDiagDB(t *testing.T) {
|
||||||
|
if _, err := os.Stat(realCfg); err != nil {
|
||||||
|
t.Skip("config.db not present, skipping")
|
||||||
|
}
|
||||||
|
p := &Plugin{name: "recoverydiag"}
|
||||||
|
r, err := p.handleDB(map[string]interface{}{"db_path": realCfg})
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("handleDB: %v", err)
|
||||||
|
}
|
||||||
|
m := r.(map[string]interface{})
|
||||||
|
t.Logf("integrity=%v sources=%v verdict=%v summary=%v", m["integrity"], m["source_count"], m["verdict"], m["summary"])
|
||||||
|
if m["integrity"] != "ok" {
|
||||||
|
t.Errorf("integrity = %v, want ok", m["integrity"])
|
||||||
|
}
|
||||||
|
if m["source_count"] == 0 {
|
||||||
|
t.Errorf("source_count == 0, expected LLM sources")
|
||||||
|
}
|
||||||
|
if got, _ := m["source_failed"].(int); got != 0 {
|
||||||
|
t.Errorf("source_failed = %d, want 0 (all sources OK): %v", got, m["missing_fields"])
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestDiagLogScan(t *testing.T) {
|
||||||
|
if _, err := os.Stat(realLog); err != nil {
|
||||||
|
t.Skip("log dir not present, skipping")
|
||||||
|
}
|
||||||
|
p := &Plugin{name: "recoverydiag"}
|
||||||
|
r, err := p.handleLogScan(map[string]interface{}{
|
||||||
|
"log_dir": realLog,
|
||||||
|
"since_minutes": 60 * 24 * 3,
|
||||||
|
})
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("handleLogScan: %v", err)
|
||||||
|
}
|
||||||
|
m := r.(map[string]interface{})
|
||||||
|
t.Logf("matched=%v counts=%v dominant=%v conclusion=%v", m["lines_matched"], m["counts"], m["dominant"], m["conclusion"])
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestDiagDelta(t *testing.T) {
|
||||||
|
base := t.TempDir()
|
||||||
|
cur := t.TempDir()
|
||||||
|
sub := filepath.Join(base, "sub")
|
||||||
|
os.MkdirAll(sub, 0755)
|
||||||
|
|
||||||
|
// modified: same path, different content
|
||||||
|
os.WriteFile(filepath.Join(base, "a.txt"), []byte("hello"), 0644)
|
||||||
|
os.WriteFile(filepath.Join(cur, "a.txt"), []byte("world!"), 0644)
|
||||||
|
// created
|
||||||
|
os.WriteFile(filepath.Join(cur, "b.txt"), []byte("new"), 0644)
|
||||||
|
// deleted
|
||||||
|
os.WriteFile(filepath.Join(base, "gone.txt"), []byte("bye"), 0644)
|
||||||
|
// unchanged
|
||||||
|
os.WriteFile(filepath.Join(base, "same.txt"), []byte("x"), 0644)
|
||||||
|
os.WriteFile(filepath.Join(cur, "same.txt"), []byte("x"), 0644)
|
||||||
|
|
||||||
|
p := &Plugin{name: "recoverydiag"}
|
||||||
|
r, err := p.handleDelta(map[string]interface{}{"baseline_dir": base, "current_dir": cur})
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("handleDelta: %v", err)
|
||||||
|
}
|
||||||
|
m := r.(map[string]interface{})
|
||||||
|
sum := m["summary"].(map[string]int)
|
||||||
|
t.Logf("summary=%v total=%v", sum, m["total_diff"])
|
||||||
|
if sum["created"] != 1 || sum["deleted"] != 1 || sum["modified"] != 1 {
|
||||||
|
t.Errorf("summary = %v, want modified=1 created=1 deleted=1", sum)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestDiagLoc(t *testing.T) {
|
||||||
|
p := &Plugin{name: "recoverydiag"}
|
||||||
|
r, _ := p.handleLoc(map[string]interface{}{
|
||||||
|
"triage": map[string]interface{}{"class": "process_death", "verdict": "down"},
|
||||||
|
"db": map[string]interface{}{"verdict": "ok"},
|
||||||
|
"log_scan": map[string]interface{}{"dominant": "panic"},
|
||||||
|
"delta": map[string]interface{}{"summary": map[string]interface{}{"created": 0, "modified": 0, "deleted": 0}},
|
||||||
|
})
|
||||||
|
m := r.(map[string]interface{})
|
||||||
|
// 经 JSON 往返,模拟内核把子结论以 JSON 传给 diag_loc 的真实路径
|
||||||
|
raw, _ := json.Marshal(m)
|
||||||
|
var dec map[string]interface{}
|
||||||
|
json.Unmarshal(raw, &dec)
|
||||||
|
hs := dec["ranked_hypotheses"].([]interface{})
|
||||||
|
if len(hs) == 0 {
|
||||||
|
t.Fatal("no hypotheses")
|
||||||
|
}
|
||||||
|
top := hs[0].(map[string]interface{})
|
||||||
|
t.Logf("top cause=%v conf=%v rec=%v", top["cause"], top["confidence"], top["recommendation"])
|
||||||
|
if top["cause"] != "code_panic_loop" {
|
||||||
|
t.Errorf("expected code_panic_loop, got %v", top["cause"])
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestDiagLocPersist(t *testing.T) {
|
||||||
|
kb := filepath.Join(t.TempDir(), "recovery_kb")
|
||||||
|
p := &Plugin{name: "recoverydiag", dataDir: filepath.Dir(kb)}
|
||||||
|
args := map[string]interface{}{
|
||||||
|
"persist": true,
|
||||||
|
"triage": map[string]interface{}{"class": "process_death", "verdict": "down"},
|
||||||
|
"db": map[string]interface{}{"verdict": "ok"},
|
||||||
|
"log_scan": map[string]interface{}{"dominant": "panic"},
|
||||||
|
"delta": map[string]interface{}{"summary": map[string]interface{}{"created": 0, "modified": 0, "deleted": 0}},
|
||||||
|
}
|
||||||
|
if _, err := p.handleLoc(args); err != nil {
|
||||||
|
t.Fatalf("handleLoc: %v", err)
|
||||||
|
}
|
||||||
|
entries, err := os.ReadDir(kb)
|
||||||
|
if err != nil || len(entries) == 0 {
|
||||||
|
t.Fatalf("expected persisted diag json, got err=%v entries=%v", err, entries)
|
||||||
|
}
|
||||||
|
data, _ := os.ReadFile(filepath.Join(kb, entries[0].Name()))
|
||||||
|
if !strings.Contains(string(data), `"cause"`) {
|
||||||
|
t.Errorf("persisted file missing cause field: %s", data)
|
||||||
|
}
|
||||||
|
}
|
||||||
7
example/recoverydiag/go.mod
Normal file
7
example/recoverydiag/go.mod
Normal file
@ -0,0 +1,7 @@
|
|||||||
|
module recoverydiag
|
||||||
|
|
||||||
|
go 1.25.0
|
||||||
|
|
||||||
|
require gitcode.com/JianFeeeee/homeagent-sdk v0.0.0
|
||||||
|
|
||||||
|
replace gitcode.com/JianFeeeee/homeagent-sdk => ../../
|
||||||
11
example/recoverydiag/main.go
Normal file
11
example/recoverydiag/main.go
Normal file
@ -0,0 +1,11 @@
|
|||||||
|
//go:build !windows || !cgo
|
||||||
|
|
||||||
|
package main
|
||||||
|
|
||||||
|
import (
|
||||||
|
sdk "gitcode.com/JianFeeeee/homeagent-sdk/sdk"
|
||||||
|
)
|
||||||
|
|
||||||
|
func NewPlugin(name string, config map[string]interface{}) (sdk.Plugin, error) {
|
||||||
|
return NewPluginFactory(name, config)
|
||||||
|
}
|
||||||
21
example/recoverydiag/plg.json
Normal file
21
example/recoverydiag/plg.json
Normal file
@ -0,0 +1,21 @@
|
|||||||
|
{
|
||||||
|
"name": "recoverydiag",
|
||||||
|
"name_zh": "恢复诊断",
|
||||||
|
"name_en": "Recovery Diagnostics",
|
||||||
|
"version": "0.2.0",
|
||||||
|
"description": "快速检查/崩溃取证工具集:diag_triage(退出码/信号/存活粗分)、diag_db(config.db 完整性 + LLM 源解析校验)、diag_log_scan(日志签名命中)、diag_delta(last-good 快照 vs 现状 diff)、diag_loc(正交综合定位)。全部返回结论而非原文,确定性、不消耗 LLM token,供 guard / failback 恢复决策使用。",
|
||||||
|
"author": "HomeAgent",
|
||||||
|
"entry": "plugin.so",
|
||||||
|
"tags": [
|
||||||
|
"diag",
|
||||||
|
"recovery",
|
||||||
|
"diagnostics",
|
||||||
|
"triage",
|
||||||
|
"failback"
|
||||||
|
],
|
||||||
|
"targets": "linux/amd64",
|
||||||
|
"outdir": "dist",
|
||||||
|
"bundle": true,
|
||||||
|
"replaces": {},
|
||||||
|
"source_dirs": []
|
||||||
|
}
|
||||||
1023
example/recoverydiag/plugin.go
Normal file
1023
example/recoverydiag/plugin.go
Normal file
File diff suppressed because it is too large
Load Diff
@ -2,14 +2,19 @@
|
|||||||
"name": "rss",
|
"name": "rss",
|
||||||
"name_zh": "RSS订阅",
|
"name_zh": "RSS订阅",
|
||||||
"name_en": "RSS",
|
"name_en": "RSS",
|
||||||
"version": "1.0.0",
|
"version": "1.1.0",
|
||||||
"description": "RSS/Atom 订阅监控插件,自动检测更新并推送通知",
|
"description": "RSS/Atom 订阅监控插件,自动检测更新并推送通知",
|
||||||
"author": "HomeAgent",
|
"author": "HomeAgent",
|
||||||
"entry": "plugin.so",
|
"entry": "plugin.so",
|
||||||
"tags": ["rss", "feed", "subscription", "monitor"],
|
"tags": [
|
||||||
|
"rss",
|
||||||
|
"feed",
|
||||||
|
"subscription",
|
||||||
|
"monitor"
|
||||||
|
],
|
||||||
"targets": "linux/amd64",
|
"targets": "linux/amd64",
|
||||||
"outdir": "dist",
|
"outdir": "dist",
|
||||||
"bundle": true,
|
"bundle": true,
|
||||||
"replaces": {},
|
"replaces": {},
|
||||||
"source_dirs": []
|
"source_dirs": []
|
||||||
}
|
}
|
||||||
@ -467,7 +467,7 @@ func (p *Plugin) saveData() {
|
|||||||
SeenGUIDs: p.seenGUIDs,
|
SeenGUIDs: p.seenGUIDs,
|
||||||
}
|
}
|
||||||
b, _ := json.MarshalIndent(data, "", " ")
|
b, _ := json.MarshalIndent(data, "", " ")
|
||||||
os.WriteFile(p.dataFile(), b, 0644)
|
atomicWriteJSON(p.dataFile(), b)
|
||||||
}
|
}
|
||||||
|
|
||||||
// cleanupData 卸载时清理订阅数据目录(feeds.json 等)
|
// cleanupData 卸载时清理订阅数据目录(feeds.json 等)
|
||||||
@ -486,3 +486,12 @@ func (p *Plugin) cleanupData() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
// atomicWriteJSON 原子写 JSON:先写临时文件再 rename,避免进程崩溃截断数据文件。
|
||||||
|
func atomicWriteJSON(path string, data []byte) error {
|
||||||
|
tmp := path + ".tmp"
|
||||||
|
if err := os.WriteFile(tmp, data, 0644); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
return os.Rename(tmp, path)
|
||||||
|
}
|
||||||
|
|||||||
@ -6,7 +6,7 @@ package meta
|
|||||||
var (
|
var (
|
||||||
// Version 是 HomeAgent SDK 版本号。
|
// Version 是 HomeAgent SDK 版本号。
|
||||||
// 通过 `-ldflags="-X gitcode.com/JianFeeeee/homeagent-sdk/meta.Version=vX.Y.Z"` 注入。
|
// 通过 `-ldflags="-X gitcode.com/JianFeeeee/homeagent-sdk/meta.Version=vX.Y.Z"` 注入。
|
||||||
Version = "0.9.0"
|
Version = "0.9.2"
|
||||||
|
|
||||||
// Commit 是构建时的 Git commit hash。
|
// Commit 是构建时的 Git commit hash。
|
||||||
Commit = "unknown"
|
Commit = "unknown"
|
||||||
@ -21,7 +21,7 @@ var (
|
|||||||
CoreModule = "gitcode.com/JianFeeeee/HomeAgent"
|
CoreModule = "gitcode.com/JianFeeeee/HomeAgent"
|
||||||
|
|
||||||
// CoreVersion 是此 SDK 所兼容的最低核心版本。
|
// CoreVersion 是此 SDK 所兼容的最低核心版本。
|
||||||
CoreVersion = "0.9.0"
|
CoreVersion = "0.9.2"
|
||||||
)
|
)
|
||||||
|
|
||||||
// FullVersion 返回完整的版本字符串。
|
// FullVersion 返回完整的版本字符串。
|
||||||
@ -99,4 +99,9 @@ const (
|
|||||||
CoreSettingsDefs = 43
|
CoreSettingsDefs = 43
|
||||||
CoreSettingsDump = 44
|
CoreSettingsDump = 44
|
||||||
CoreSettingsPlugins = 45
|
CoreSettingsPlugins = 45
|
||||||
|
CoreRegisterInputCh = 46
|
||||||
|
CoreInjectInputSync = 47
|
||||||
|
CorePluginReloadOne = 48
|
||||||
|
CorePluginListLoaded = 49
|
||||||
|
CorePluginIsDisabled = 50
|
||||||
)
|
)
|
||||||
|
|||||||
116
remotedevice/CMakeLists.txt
Normal file
116
remotedevice/CMakeLists.txt
Normal file
@ -0,0 +1,116 @@
|
|||||||
|
cmake_minimum_required(VERSION 3.10)
|
||||||
|
project(ha_remotedevice VERSION 0.1.0 LANGUAGES C)
|
||||||
|
|
||||||
|
# ============================================================
|
||||||
|
# ha_remotedevice — HomeAgent 远程设备接入 C SDK
|
||||||
|
# 零外部依赖,纯 C 实现,兼容嵌入式平台。
|
||||||
|
#
|
||||||
|
# 使用方式:
|
||||||
|
# add_subdirectory(path/to/ha_remotedevice)
|
||||||
|
# target_link_libraries(my_app ha_remotedevice)
|
||||||
|
# target_include_directories(my_app PRIVATE
|
||||||
|
# ${HA_REMOTEDEVICE_INCLUDE_DIR})
|
||||||
|
# ============================================================
|
||||||
|
|
||||||
|
# 选项: 构建为静态库或动态库
|
||||||
|
option(BUILD_SHARED_LIBS "Build ha_remotedevice as shared library" OFF)
|
||||||
|
|
||||||
|
# 选项: 禁用 malloc/free(用于裸机环境,用户需提供 alloc 回调)
|
||||||
|
option(HA_NO_ALLOC "Disable dynamic memory allocation" OFF)
|
||||||
|
|
||||||
|
# 选项: 日志级别
|
||||||
|
set(HA_LOG_LEVEL 2 CACHE STRING "Log level: 0=none, 1=error, 2=info, 3=debug")
|
||||||
|
|
||||||
|
# 源文件
|
||||||
|
set(HA_REMOTEDEVICE_SRC
|
||||||
|
src/ha_remotedevice.c
|
||||||
|
src/ha_json.c
|
||||||
|
src/ha_ws.c
|
||||||
|
)
|
||||||
|
|
||||||
|
# 头文件
|
||||||
|
set(HA_REMOTEDEVICE_INCLUDE
|
||||||
|
${CMAKE_CURRENT_SOURCE_DIR}/include
|
||||||
|
)
|
||||||
|
|
||||||
|
# 编译选项
|
||||||
|
if(HA_NO_ALLOC)
|
||||||
|
add_definitions(-DHA_NO_ALLOC)
|
||||||
|
endif()
|
||||||
|
add_definitions(-DHA_LOG_LEVEL=${HA_LOG_LEVEL})
|
||||||
|
|
||||||
|
# 创建库
|
||||||
|
if(BUILD_SHARED_LIBS)
|
||||||
|
add_library(ha_remotedevice SHARED ${HA_REMOTEDEVICE_SRC})
|
||||||
|
if(WIN32)
|
||||||
|
# Windows 需要导出符号
|
||||||
|
set_target_properties(ha_remotedevice PROPERTIES
|
||||||
|
WINDOWS_EXPORT_ALL_SYMBOLS ON)
|
||||||
|
endif()
|
||||||
|
else()
|
||||||
|
add_library(ha_remotedevice STATIC ${HA_REMOTEDEVICE_SRC})
|
||||||
|
endif()
|
||||||
|
|
||||||
|
# 包含目录
|
||||||
|
target_include_directories(ha_remotedevice
|
||||||
|
PUBLIC ${HA_REMOTEDEVICE_INCLUDE}
|
||||||
|
PRIVATE ${CMAKE_CURRENT_SOURCE_DIR}/src
|
||||||
|
)
|
||||||
|
|
||||||
|
# 不链接任何外部库
|
||||||
|
target_link_libraries(ha_remotedevice PRIVATE)
|
||||||
|
|
||||||
|
# 导出包含目录供外部项目使用
|
||||||
|
set(HA_REMOTEDEVICE_INCLUDE_DIR
|
||||||
|
${HA_REMOTEDEVICE_INCLUDE}
|
||||||
|
CACHE INTERNAL "ha_remotedevice include directories")
|
||||||
|
|
||||||
|
# 安装规则
|
||||||
|
install(TARGETS ha_remotedevice
|
||||||
|
EXPORT ha_remotedevice-targets
|
||||||
|
LIBRARY DESTINATION lib
|
||||||
|
ARCHIVE DESTINATION lib
|
||||||
|
RUNTIME DESTINATION bin
|
||||||
|
INCLUDES DESTINATION include
|
||||||
|
)
|
||||||
|
|
||||||
|
install(DIRECTORY include/
|
||||||
|
DESTINATION include
|
||||||
|
)
|
||||||
|
|
||||||
|
install(EXPORT ha_remotedevice-targets
|
||||||
|
DESTINATION lib/cmake/ha_remotedevice
|
||||||
|
NAMESPACE ha_remotedevice::
|
||||||
|
)
|
||||||
|
|
||||||
|
# ============================================================
|
||||||
|
# 测试(可选)
|
||||||
|
# ============================================================
|
||||||
|
option(BUILD_TESTS "Build ha_remotedevice tests" OFF)
|
||||||
|
|
||||||
|
if(BUILD_TESTS)
|
||||||
|
find_package(Threads REQUIRED)
|
||||||
|
|
||||||
|
add_executable(ha_remotedevice_test
|
||||||
|
test/test_ha_remotedevice.c
|
||||||
|
)
|
||||||
|
target_link_libraries(ha_remotedevice_test
|
||||||
|
PRIVATE ha_remotedevice Threads::Threads
|
||||||
|
)
|
||||||
|
target_include_directories(ha_remotedevice_test
|
||||||
|
PRIVATE ${HA_REMOTEDEVICE_INCLUDE_DIR}
|
||||||
|
)
|
||||||
|
|
||||||
|
# 添加测试
|
||||||
|
add_test(NAME ha_remotedevice_test
|
||||||
|
COMMAND ha_remotedevice_test
|
||||||
|
)
|
||||||
|
endif()
|
||||||
|
|
||||||
|
# ============================================================
|
||||||
|
# 编译信息
|
||||||
|
# ============================================================
|
||||||
|
message(STATUS "ha_remotedevice ${PROJECT_VERSION}")
|
||||||
|
message(STATUS " Build type: $<CONFIG>")
|
||||||
|
message(STATUS " Shared lib: ${BUILD_SHARED_LIBS}")
|
||||||
|
message(STATUS " No alloc: ${HA_NO_ALLOC}")
|
||||||
216
remotedevice/include/ha_remotedevice.h
Normal file
216
remotedevice/include/ha_remotedevice.h
Normal file
@ -0,0 +1,216 @@
|
|||||||
|
#ifndef HA_REMOTEDEVICE_H
|
||||||
|
#define HA_REMOTEDEVICE_H
|
||||||
|
|
||||||
|
#include <stdint.h>
|
||||||
|
#include <stddef.h>
|
||||||
|
|
||||||
|
#ifdef __cplusplus
|
||||||
|
extern "C" {
|
||||||
|
#endif
|
||||||
|
|
||||||
|
/* ==================================================================
|
||||||
|
* ha_remotedevice — 远程设备接入 C SDK
|
||||||
|
*
|
||||||
|
* 零外部依赖,纯 C 实现,兼容嵌入式平台。
|
||||||
|
* 传输层由用户实现(4 个函数指针),SDK 处理所有协议细节。
|
||||||
|
*
|
||||||
|
* 声明式设计:
|
||||||
|
* 设备在代码中声明自己是什么(kind)和能做什么(caps),
|
||||||
|
* 声明支持哪些命令(shell/camerasue/screensee/...)并注册对应处理函数,
|
||||||
|
* SDK 自动处理协议握手、心跳、消息路由、结果回执。
|
||||||
|
*
|
||||||
|
* 协议流程:
|
||||||
|
* TCP 连接 → WS 升级 → hello(设备声明) → bind(令牌) → 就绪
|
||||||
|
* 就绪后循环:读帧 → 按 handlers 表分发命令 → 自动回执结果
|
||||||
|
* ================================================================== */
|
||||||
|
|
||||||
|
/* ======================== 状态码 ======================== */
|
||||||
|
typedef enum {
|
||||||
|
HA_OK = 0,
|
||||||
|
HA_ERR_GENERIC = -1,
|
||||||
|
HA_ERR_NOMEM = -2,
|
||||||
|
HA_ERR_INVALID = -3,
|
||||||
|
HA_ERR_TIMEOUT = -4,
|
||||||
|
HA_ERR_DISCONNECTED = -5,
|
||||||
|
HA_ERR_PROTOCOL = -6,
|
||||||
|
HA_ERR_TRANSPORT = -7,
|
||||||
|
HA_ERR_NOT_FOUND = -8,
|
||||||
|
} ha_status_t;
|
||||||
|
|
||||||
|
/* ======================== 传输层抽象 ========================
|
||||||
|
*
|
||||||
|
* 用户必须实现这 4 个函数,适配不同平台(FreeRTOS+lwIP、Zephyr、裸机等)。
|
||||||
|
*
|
||||||
|
* connect(ctx, host, port) → 建立 TCP 连接,返回 0 成功
|
||||||
|
* send(ctx, data, len) → 发送 len 字节,返回实际发送字节数,-1 失败
|
||||||
|
* recv(ctx, buf, len) → 接收最多 len 字节,返回实际接收字节数,0 断开,-1 失败
|
||||||
|
* close(ctx) → 关闭连接
|
||||||
|
*/
|
||||||
|
typedef struct {
|
||||||
|
int (*connect)(void *ctx, const char *host, uint16_t port);
|
||||||
|
int (*send)(void *ctx, const uint8_t *data, int len);
|
||||||
|
int (*recv)(void *ctx, uint8_t *buf, int len);
|
||||||
|
void (*close)(void *ctx);
|
||||||
|
void *ctx;
|
||||||
|
} ha_transport_t;
|
||||||
|
|
||||||
|
/* ======================== 设备声明 ========================
|
||||||
|
*
|
||||||
|
* 声明式配置:设备在代码中声明自己的类型和能力。
|
||||||
|
* 这些信息通过 hello 消息发送给网关。
|
||||||
|
*
|
||||||
|
* device_id — 唯一标识,如 "esp32-cam-1"
|
||||||
|
* name — 设备显示名,如 "门口摄像头"
|
||||||
|
* kind — 设备种类,如 "camera"、"computer"、"speaker"、"light"
|
||||||
|
* caps — 能力数组,以 NULL 结尾,如 {"camera","status",NULL}
|
||||||
|
* info_json — 额外信息(JSON 字符串),可选,如 '{"chip":"ESP32-S3","psram":8}'
|
||||||
|
*/
|
||||||
|
typedef struct {
|
||||||
|
const char *device_id;
|
||||||
|
const char *name;
|
||||||
|
const char *kind;
|
||||||
|
const char **caps; /* NULL 结尾 */
|
||||||
|
const char *info_json; /* 可选,NULL 或 JSON 字符串 */
|
||||||
|
} ha_device_info_t;
|
||||||
|
|
||||||
|
/* ======================== 命令结果 ========================
|
||||||
|
*
|
||||||
|
* 命令处理函数通过填写此结构体返回数据。
|
||||||
|
* SDK 收到结果后自动发送回执(文本或二进制分块)。
|
||||||
|
*
|
||||||
|
* 使用方式:
|
||||||
|
* 1. 简单文本:设置 status=0, output="结果文本"
|
||||||
|
* 2. 二进制数据:设置 has_binary=1, binary_data/binary_len/mime
|
||||||
|
* 3. 错误:设置 status=1, error="错误信息"
|
||||||
|
*
|
||||||
|
* 注意:output 字符串由 SDK 内部 strdup 后发送,handler 返回后即可释放。
|
||||||
|
* 我们约定 handler 不负责分配,由 SDK 在内部做好拷贝。
|
||||||
|
* 所以 handler 可以返回栈上或静态字符串。
|
||||||
|
*/
|
||||||
|
typedef struct {
|
||||||
|
int status; /* 0=ok, 非0=error */
|
||||||
|
const char *output; /* 输出文本(如 base64 图像数据),SDK 内部拷贝 */
|
||||||
|
const char *error; /* 错误信息 */
|
||||||
|
int has_binary; /* 1=通过二进制分块回传 */
|
||||||
|
const char *binary_mime; /* 二进制 MIME 类型 */
|
||||||
|
const uint8_t *binary_data; /* 二进制数据指针 */
|
||||||
|
int binary_len; /* 二进制数据长度 */
|
||||||
|
} ha_cmd_result_t;
|
||||||
|
|
||||||
|
/* ======================== 命令处理声明 ========================
|
||||||
|
*
|
||||||
|
* 声明式命令注册:设备在配置中声明支持哪些命令,并绑定处理函数。
|
||||||
|
*
|
||||||
|
* command 值说明:
|
||||||
|
* - "shell" → 处理 shell 类型命令,args 为完整命令字符串
|
||||||
|
* - "camerasue" → 处理 homeagent-camerasue 命令,args 为参数
|
||||||
|
* - "screensee" → 处理 homeagent-screensee 命令
|
||||||
|
* - "speakeruse" → 处理 homeagent-speakeruse 命令
|
||||||
|
* - "computeruse" → 处理 homeagent-computeruse 命令
|
||||||
|
* - "clipboardsee" → 处理 homeagent-clipboardsee 命令
|
||||||
|
* - "clipboardsue" → 处理 homeagent-clipboardsue 命令
|
||||||
|
* - "screensue" → 处理 homeagent-screensue 命令
|
||||||
|
* - "deviceinfo" → 处理设备信息查询
|
||||||
|
* - 其他自定义命令名 → 按字符串匹配分发
|
||||||
|
*
|
||||||
|
* handler 处理完毕后只需填写 result 结构体,SDK 自动回执。
|
||||||
|
*/
|
||||||
|
typedef ha_status_t (*ha_cmd_handler_t)(const char *req_id, const char *args,
|
||||||
|
ha_cmd_result_t *result, void *userdata);
|
||||||
|
|
||||||
|
typedef struct {
|
||||||
|
const char *command; /* 命令名,如 "camerasue"、"shell" */
|
||||||
|
ha_cmd_handler_t handler; /* 处理函数 */
|
||||||
|
} ha_cmd_handler_def_t;
|
||||||
|
|
||||||
|
/* 二进制数据接收回调:收到服务端推送的二进制数据(如 TTS 音频)时调用。
|
||||||
|
* data 指针在回调返回后失效,如需保存请拷贝。 */
|
||||||
|
typedef void (*ha_binary_handler_t)(const char *req_id, const char *kind,
|
||||||
|
const char *mime, const uint8_t *data,
|
||||||
|
int len, void *userdata);
|
||||||
|
|
||||||
|
/* 连接状态变化回调 */
|
||||||
|
typedef void (*ha_state_callback_t)(int connected, void *userdata);
|
||||||
|
|
||||||
|
/* ======================== 客户端配置 ========================
|
||||||
|
*
|
||||||
|
* 所有配置在 ha_client_new() 时一次性声明。
|
||||||
|
* 声明式核心:handlers 表声明了设备支持的所有命令及其处理函数。
|
||||||
|
*/
|
||||||
|
typedef struct {
|
||||||
|
ha_transport_t transport; /* 传输层实现(必须) */
|
||||||
|
ha_device_info_t device; /* 设备声明(必须) */
|
||||||
|
const char *server; /* 服务端地址,如 "192.168.1.100:9890"(必须) */
|
||||||
|
const char *token; /* 接入令牌(必须) */
|
||||||
|
|
||||||
|
ha_cmd_handler_def_t *handlers; /* 声明式命令处理表,.command=NULL 标记结束 */
|
||||||
|
ha_binary_handler_t on_binary; /* 二进制数据接收回调(可选) */
|
||||||
|
ha_state_callback_t on_state; /* 状态变化回调(可选) */
|
||||||
|
void *userdata; /* 用户自定义数据,传给所有回调 */
|
||||||
|
|
||||||
|
int ping_interval; /* 心跳间隔秒数,0 则默认 30 */
|
||||||
|
int max_reconnect; /* 最大重连次数,-1 无限重连(默认),0 不重连 */
|
||||||
|
} ha_config_t;
|
||||||
|
|
||||||
|
/* ======================== 客户端 API ======================== */
|
||||||
|
|
||||||
|
typedef struct ha_client ha_client_t;
|
||||||
|
|
||||||
|
/* 创建客户端实例。config 数据会在内部拷贝,外部可释放。 */
|
||||||
|
ha_client_t *ha_client_new(const ha_config_t *config);
|
||||||
|
|
||||||
|
/* 启动连接:TCP 连接 → WS 升级 → hello → bind → 就绪。阻塞直到完成或失败。 */
|
||||||
|
ha_status_t ha_client_start(ha_client_t *client);
|
||||||
|
|
||||||
|
/* 主循环处理:必须在用户的主循环中周期性调用。
|
||||||
|
* - 读取 WS 帧并分发
|
||||||
|
* - 按 handlers 表查找命令处理函数,自动回执结果
|
||||||
|
* - 处理心跳 ping/pong
|
||||||
|
* - 处理断线重连
|
||||||
|
* 返回 HA_OK 表示正常,HA_ERR_DISCONNECTED 表示正在重连。 */
|
||||||
|
ha_status_t ha_client_process(ha_client_t *client);
|
||||||
|
|
||||||
|
/* ===== 主动上报(设备主动推送,非命令响应) ===== */
|
||||||
|
|
||||||
|
/* 发送设备主动上报事件。type 如 "motion_detected",detail 为 JSON 字符串。 */
|
||||||
|
void ha_client_send_event(ha_client_t *client, const char *type,
|
||||||
|
const char *detail);
|
||||||
|
|
||||||
|
/* 发送设备状态更新。status: "online"、"offline"、"busy" 等。 */
|
||||||
|
void ha_client_send_status(ha_client_t *client, const char *status);
|
||||||
|
|
||||||
|
/* ===== 生命周期 ===== */
|
||||||
|
|
||||||
|
/* 停止客户端,断开连接。 */
|
||||||
|
void ha_client_stop(ha_client_t *client);
|
||||||
|
|
||||||
|
/* 销毁客户端,释放所有资源。 */
|
||||||
|
void ha_client_destroy(ha_client_t *client);
|
||||||
|
|
||||||
|
/* ======================== 工具函数 ======================== */
|
||||||
|
|
||||||
|
/* 解析 homeagent-* 命令,返回能力名和参数。
|
||||||
|
* command = "camerasue 5" → cap="camerasue", args="5"
|
||||||
|
* command = "screensee" → cap="screensee", args=""
|
||||||
|
* command = "computeruse {...}" → cap="computeruse", args="..." */
|
||||||
|
void ha_cmd_parse_homeagent(const char *command, const char **cap,
|
||||||
|
const char **args);
|
||||||
|
|
||||||
|
/* 解析 JSON 格式的命令参数,提取 action 和 JSON 字符串。
|
||||||
|
* command = "computeruse {\"action\":\"click\",\"x\":100}"
|
||||||
|
* → action="computeruse", json_str="{\"action\":\"click\",...}" */
|
||||||
|
void ha_cmd_parse_json(const char *command, const char **action,
|
||||||
|
const char **json_str);
|
||||||
|
|
||||||
|
/* Base64 编码(用于将二进制数据编码为文本回传)。
|
||||||
|
* 返回写入 out 的字节数(不含 \0),out 不足时返回所需长度。 */
|
||||||
|
int ha_base64_encode(const uint8_t *data, int len, char *out, int out_len);
|
||||||
|
|
||||||
|
/* 获取版本号 */
|
||||||
|
const char *ha_version(void);
|
||||||
|
|
||||||
|
#ifdef __cplusplus
|
||||||
|
}
|
||||||
|
#endif
|
||||||
|
|
||||||
|
#endif /* HA_REMOTEDEVICE_H */
|
||||||
369
remotedevice/src/ha_json.c
Normal file
369
remotedevice/src/ha_json.c
Normal file
@ -0,0 +1,369 @@
|
|||||||
|
#include "ha_json.h"
|
||||||
|
#include <stdlib.h>
|
||||||
|
#include <string.h>
|
||||||
|
#include <ctype.h>
|
||||||
|
#include <stdio.h>
|
||||||
|
|
||||||
|
/* ======================== 解析器 ======================== */
|
||||||
|
|
||||||
|
/* 前向声明 */
|
||||||
|
static ha_json_node_t *parse_value(const char **pp);
|
||||||
|
|
||||||
|
/* 跳过空白 */
|
||||||
|
static const char *skip_ws(const char *p) {
|
||||||
|
while (*p && (unsigned char)*p <= ' ') p++;
|
||||||
|
return p;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* 解析字符串("..."),返回新分配的字符串,p 更新到结束引号后 */
|
||||||
|
static char *parse_string(const char **pp) {
|
||||||
|
const char *p = skip_ws(*pp);
|
||||||
|
if (*p != '"') return NULL;
|
||||||
|
p++;
|
||||||
|
int len = 0;
|
||||||
|
const char *q = p;
|
||||||
|
while (*q && *q != '"') {
|
||||||
|
if (*q == '\\') { q++; if (*q) q++; }
|
||||||
|
else q++;
|
||||||
|
len++;
|
||||||
|
}
|
||||||
|
if (*q != '"') return NULL;
|
||||||
|
char *s = (char *)malloc(len + 1);
|
||||||
|
if (!s) return NULL;
|
||||||
|
q = p;
|
||||||
|
int i = 0;
|
||||||
|
while (*q && *q != '"') {
|
||||||
|
if (*q == '\\') {
|
||||||
|
q++;
|
||||||
|
switch (*q) {
|
||||||
|
case '"': s[i++] = '"'; break;
|
||||||
|
case '\\': s[i++] = '\\'; break;
|
||||||
|
case '/': s[i++] = '/'; break;
|
||||||
|
case 'b': s[i++] = '\b'; break;
|
||||||
|
case 'f': s[i++] = '\f'; break;
|
||||||
|
case 'n': s[i++] = '\n'; break;
|
||||||
|
case 'r': s[i++] = '\r'; break;
|
||||||
|
case 't': s[i++] = '\t'; break;
|
||||||
|
case 'u': q += 4; s[i++] = '?'; continue;
|
||||||
|
default: s[i++] = *q; break;
|
||||||
|
}
|
||||||
|
q++;
|
||||||
|
} else {
|
||||||
|
s[i++] = *q++;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
s[i] = '\0';
|
||||||
|
*pp = q + 1;
|
||||||
|
return s;
|
||||||
|
}
|
||||||
|
|
||||||
|
static ha_json_node_t *new_node(ha_json_type_t type) {
|
||||||
|
ha_json_node_t *n = (ha_json_node_t *)calloc(1, sizeof(ha_json_node_t));
|
||||||
|
if (n) n->type = type;
|
||||||
|
return n;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* 解析数字 */
|
||||||
|
static ha_json_node_t *parse_number(const char **pp) {
|
||||||
|
const char *p = *pp;
|
||||||
|
int neg = 0;
|
||||||
|
if (*p == '-') { neg = 1; p++; }
|
||||||
|
if (!isdigit((unsigned char)*p)) return NULL;
|
||||||
|
int val = 0;
|
||||||
|
while (isdigit((unsigned char)*p)) {
|
||||||
|
val = val * 10 + (*p - '0');
|
||||||
|
p++;
|
||||||
|
}
|
||||||
|
if (*p == '.') { p++; while (isdigit((unsigned char)*p)) p++; }
|
||||||
|
if (*p == 'e' || *p == 'E') {
|
||||||
|
p++;
|
||||||
|
if (*p == '+' || *p == '-') p++;
|
||||||
|
while (isdigit((unsigned char)*p)) p++;
|
||||||
|
}
|
||||||
|
*pp = p;
|
||||||
|
ha_json_node_t *n = new_node(HA_JSON_INT);
|
||||||
|
if (n) n->int_val = neg ? -val : val;
|
||||||
|
return n;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* 解析 true/false/null */
|
||||||
|
static ha_json_node_t *parse_keyword(const char **pp) {
|
||||||
|
const char *p = *pp;
|
||||||
|
ha_json_node_t *n = NULL;
|
||||||
|
if (strncmp(p, "true", 4) == 0 && !isalnum((unsigned char)p[4])) {
|
||||||
|
n = new_node(HA_JSON_BOOL); if (n) n->bool_val = 1;
|
||||||
|
*pp = p + 4;
|
||||||
|
} else if (strncmp(p, "false", 5) == 0 && !isalnum((unsigned char)p[5])) {
|
||||||
|
n = new_node(HA_JSON_BOOL); if (n) n->bool_val = 0;
|
||||||
|
*pp = p + 5;
|
||||||
|
} else if (strncmp(p, "null", 4) == 0 && !isalnum((unsigned char)p[4])) {
|
||||||
|
n = new_node(HA_JSON_NULL);
|
||||||
|
*pp = p + 4;
|
||||||
|
}
|
||||||
|
return n;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* 解析对象 */
|
||||||
|
static ha_json_node_t *parse_object(const char **pp) {
|
||||||
|
const char *p = skip_ws(*pp);
|
||||||
|
if (*p != '{') return NULL;
|
||||||
|
p++;
|
||||||
|
ha_json_node_t *obj = new_node(HA_JSON_OBJECT);
|
||||||
|
if (!obj) return NULL;
|
||||||
|
ha_json_node_t **tail = &obj->child;
|
||||||
|
p = skip_ws(p);
|
||||||
|
if (*p == '}') { *pp = p + 1; return obj; }
|
||||||
|
while (*p) {
|
||||||
|
p = skip_ws(p);
|
||||||
|
char *key = parse_string(&p);
|
||||||
|
if (!key) break;
|
||||||
|
p = skip_ws(p);
|
||||||
|
if (*p != ':') { free(key); break; }
|
||||||
|
p++;
|
||||||
|
ha_json_node_t *val = parse_value(&p);
|
||||||
|
if (!val) { free(key); break; }
|
||||||
|
val->key = key;
|
||||||
|
*tail = val;
|
||||||
|
tail = &val->next;
|
||||||
|
p = skip_ws(p);
|
||||||
|
if (*p == ',') { p++; continue; }
|
||||||
|
if (*p == '}') break;
|
||||||
|
}
|
||||||
|
p = skip_ws(p);
|
||||||
|
if (*p == '}') { *pp = p + 1; return obj; }
|
||||||
|
ha_json_free(obj);
|
||||||
|
return NULL;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* 解析数组 */
|
||||||
|
static ha_json_node_t *parse_array(const char **pp) {
|
||||||
|
const char *p = skip_ws(*pp);
|
||||||
|
if (*p != '[') return NULL;
|
||||||
|
p++;
|
||||||
|
ha_json_node_t *arr = new_node(HA_JSON_ARRAY);
|
||||||
|
if (!arr) return NULL;
|
||||||
|
ha_json_node_t **tail = &arr->child;
|
||||||
|
p = skip_ws(p);
|
||||||
|
if (*p == ']') { *pp = p + 1; return arr; }
|
||||||
|
while (*p) {
|
||||||
|
ha_json_node_t *val = parse_value(&p);
|
||||||
|
if (!val) break;
|
||||||
|
*tail = val;
|
||||||
|
tail = &val->next;
|
||||||
|
p = skip_ws(p);
|
||||||
|
if (*p == ',') { p++; continue; }
|
||||||
|
if (*p == ']') break;
|
||||||
|
}
|
||||||
|
p = skip_ws(p);
|
||||||
|
if (*p == ']') { *pp = p + 1; return arr; }
|
||||||
|
ha_json_free(arr);
|
||||||
|
return NULL;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* 解析值(主入口) */
|
||||||
|
static ha_json_node_t *parse_value(const char **pp) {
|
||||||
|
const char *p = skip_ws(*pp);
|
||||||
|
if (*p == '{') return parse_object(pp);
|
||||||
|
if (*p == '[') return parse_array(pp);
|
||||||
|
if (*p == '"') {
|
||||||
|
char *s = parse_string(pp);
|
||||||
|
if (!s) return NULL;
|
||||||
|
ha_json_node_t *n = new_node(HA_JSON_STRING);
|
||||||
|
if (!n) { free(s); return NULL; }
|
||||||
|
n->str_val = s;
|
||||||
|
return n;
|
||||||
|
}
|
||||||
|
if (*p == '-' || isdigit((unsigned char)*p)) return parse_number(pp);
|
||||||
|
return parse_keyword(pp);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ======================== 公共 API ======================== */
|
||||||
|
|
||||||
|
ha_json_node_t *ha_json_parse(const char *str) {
|
||||||
|
if (!str) return NULL;
|
||||||
|
const char *p = str;
|
||||||
|
return parse_value(&p);
|
||||||
|
}
|
||||||
|
|
||||||
|
const char *ha_json_get_string(const ha_json_node_t *obj, const char *key) {
|
||||||
|
ha_json_node_t *n = ha_json_get(obj, key);
|
||||||
|
if (!n || n->type != HA_JSON_STRING) return NULL;
|
||||||
|
return n->str_val;
|
||||||
|
}
|
||||||
|
|
||||||
|
int ha_json_get_int(const ha_json_node_t *obj, const char *key, int def) {
|
||||||
|
ha_json_node_t *n = ha_json_get(obj, key);
|
||||||
|
if (!n || n->type != HA_JSON_INT) return def;
|
||||||
|
return n->int_val;
|
||||||
|
}
|
||||||
|
|
||||||
|
ha_json_node_t *ha_json_get(const ha_json_node_t *obj, const char *key) {
|
||||||
|
if (!obj || obj->type != HA_JSON_OBJECT) return NULL;
|
||||||
|
ha_json_node_t *c = obj->child;
|
||||||
|
while (c) {
|
||||||
|
if (c->key && strcmp(c->key, key) == 0) return c;
|
||||||
|
c = c->next;
|
||||||
|
}
|
||||||
|
return NULL;
|
||||||
|
}
|
||||||
|
|
||||||
|
int ha_json_array_len(const ha_json_node_t *arr) {
|
||||||
|
if (!arr || arr->type != HA_JSON_ARRAY) return 0;
|
||||||
|
int n = 0;
|
||||||
|
ha_json_node_t *c = arr->child;
|
||||||
|
while (c) { n++; c = c->next; }
|
||||||
|
return n;
|
||||||
|
}
|
||||||
|
|
||||||
|
ha_json_node_t *ha_json_array_get(const ha_json_node_t *arr, int index) {
|
||||||
|
if (!arr || arr->type != HA_JSON_ARRAY) return NULL;
|
||||||
|
ha_json_node_t *c = arr->child;
|
||||||
|
int i = 0;
|
||||||
|
while (c) {
|
||||||
|
if (i == index) return c;
|
||||||
|
i++; c = c->next;
|
||||||
|
}
|
||||||
|
return NULL;
|
||||||
|
}
|
||||||
|
|
||||||
|
void ha_json_free(ha_json_node_t *root) {
|
||||||
|
if (!root) return;
|
||||||
|
ha_json_node_t *c = root->child;
|
||||||
|
while (c) {
|
||||||
|
ha_json_node_t *next = c->next;
|
||||||
|
free(c->key);
|
||||||
|
if (c->type == HA_JSON_STRING) free(c->str_val);
|
||||||
|
ha_json_free(c);
|
||||||
|
c = next;
|
||||||
|
}
|
||||||
|
free(root);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ======================== 构建器 ======================== */
|
||||||
|
|
||||||
|
static void json_escape(ha_json_builder_t *jb, const char *s) {
|
||||||
|
if (!s) { ha_json_builder_raw(jb, "null"); return; }
|
||||||
|
ha_json_builder_raw(jb, "\"");
|
||||||
|
for (const char *p = s; *p; p++) {
|
||||||
|
unsigned char c = (unsigned char)*p;
|
||||||
|
switch (c) {
|
||||||
|
case '"': ha_json_builder_raw(jb, "\\\""); break;
|
||||||
|
case '\\': ha_json_builder_raw(jb, "\\\\"); break;
|
||||||
|
case '\b': ha_json_builder_raw(jb, "\\b"); break;
|
||||||
|
case '\f': ha_json_builder_raw(jb, "\\f"); break;
|
||||||
|
case '\n': ha_json_builder_raw(jb, "\\n"); break;
|
||||||
|
case '\r': ha_json_builder_raw(jb, "\\r"); break;
|
||||||
|
case '\t': ha_json_builder_raw(jb, "\\t"); break;
|
||||||
|
default:
|
||||||
|
if (c < 0x20) {
|
||||||
|
char buf[8];
|
||||||
|
snprintf(buf, sizeof(buf), "\\u%04x", c);
|
||||||
|
ha_json_builder_raw(jb, buf);
|
||||||
|
} else {
|
||||||
|
char buf[2] = { (char)c, 0 };
|
||||||
|
ha_json_builder_raw(jb, buf);
|
||||||
|
}
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
ha_json_builder_raw(jb, "\"");
|
||||||
|
}
|
||||||
|
|
||||||
|
void ha_json_builder_init(ha_json_builder_t *jb, char *buf, int cap) {
|
||||||
|
jb->buf = buf;
|
||||||
|
jb->len = 0;
|
||||||
|
jb->cap = cap;
|
||||||
|
jb->depth = 0;
|
||||||
|
if (cap > 0) buf[0] = '\0';
|
||||||
|
}
|
||||||
|
|
||||||
|
void ha_json_builder_reset(ha_json_builder_t *jb) {
|
||||||
|
jb->len = 0;
|
||||||
|
jb->depth = 0;
|
||||||
|
if (jb->cap > 0) jb->buf[0] = '\0';
|
||||||
|
}
|
||||||
|
|
||||||
|
void ha_json_builder_raw(ha_json_builder_t *jb, const char *s) {
|
||||||
|
while (*s && jb->len < jb->cap - 1) {
|
||||||
|
jb->buf[jb->len++] = *s++;
|
||||||
|
}
|
||||||
|
jb->buf[jb->len] = '\0';
|
||||||
|
}
|
||||||
|
|
||||||
|
void ha_json_builder_comma(ha_json_builder_t *jb) {
|
||||||
|
if (jb->depth > 0 && jb->item_count[jb->depth - 1] > 0) {
|
||||||
|
ha_json_builder_raw(jb, ",");
|
||||||
|
}
|
||||||
|
if (jb->depth > 0) jb->item_count[jb->depth - 1]++;
|
||||||
|
}
|
||||||
|
|
||||||
|
void ha_json_builder_begin_object(ha_json_builder_t *jb) {
|
||||||
|
ha_json_builder_comma(jb);
|
||||||
|
ha_json_builder_raw(jb, "{");
|
||||||
|
if (jb->depth < 16) jb->item_count[jb->depth] = 0;
|
||||||
|
jb->depth++;
|
||||||
|
}
|
||||||
|
|
||||||
|
void ha_json_builder_end_object(ha_json_builder_t *jb) {
|
||||||
|
jb->depth--;
|
||||||
|
ha_json_builder_raw(jb, "}");
|
||||||
|
}
|
||||||
|
|
||||||
|
void ha_json_builder_begin_array(ha_json_builder_t *jb) {
|
||||||
|
ha_json_builder_comma(jb);
|
||||||
|
ha_json_builder_raw(jb, "[");
|
||||||
|
if (jb->depth < 16) jb->item_count[jb->depth] = 0;
|
||||||
|
jb->depth++;
|
||||||
|
}
|
||||||
|
|
||||||
|
void ha_json_builder_end_array(ha_json_builder_t *jb) {
|
||||||
|
jb->depth--;
|
||||||
|
ha_json_builder_raw(jb, "]");
|
||||||
|
}
|
||||||
|
|
||||||
|
void ha_json_builder_key(ha_json_builder_t *jb, const char *key) {
|
||||||
|
ha_json_builder_comma(jb);
|
||||||
|
json_escape(jb, key);
|
||||||
|
ha_json_builder_raw(jb, ":");
|
||||||
|
}
|
||||||
|
|
||||||
|
void ha_json_builder_add_string(ha_json_builder_t *jb, const char *val) {
|
||||||
|
json_escape(jb, val);
|
||||||
|
}
|
||||||
|
|
||||||
|
void ha_json_builder_add_int(ha_json_builder_t *jb, int val) {
|
||||||
|
char buf[16];
|
||||||
|
snprintf(buf, sizeof(buf), "%d", val);
|
||||||
|
ha_json_builder_raw(jb, buf);
|
||||||
|
}
|
||||||
|
|
||||||
|
void ha_json_builder_add_bool(ha_json_builder_t *jb, int val) {
|
||||||
|
ha_json_builder_raw(jb, val ? "true" : "false");
|
||||||
|
}
|
||||||
|
|
||||||
|
void ha_json_builder_add_null(ha_json_builder_t *jb) {
|
||||||
|
ha_json_builder_raw(jb, "null");
|
||||||
|
}
|
||||||
|
|
||||||
|
void ha_json_builder_string(ha_json_builder_t *jb, const char *key, const char *val) {
|
||||||
|
ha_json_builder_key(jb, key);
|
||||||
|
json_escape(jb, val);
|
||||||
|
}
|
||||||
|
|
||||||
|
void ha_json_builder_int(ha_json_builder_t *jb, const char *key, int val) {
|
||||||
|
ha_json_builder_key(jb, key);
|
||||||
|
ha_json_builder_add_int(jb, val);
|
||||||
|
}
|
||||||
|
|
||||||
|
void ha_json_builder_bool(ha_json_builder_t *jb, const char *key, int val) {
|
||||||
|
ha_json_builder_key(jb, key);
|
||||||
|
ha_json_builder_add_bool(jb, val);
|
||||||
|
}
|
||||||
|
|
||||||
|
const char *ha_json_builder_str(ha_json_builder_t *jb) {
|
||||||
|
return jb->buf;
|
||||||
|
}
|
||||||
|
|
||||||
|
int ha_json_builder_len(ha_json_builder_t *jb) {
|
||||||
|
return jb->len;
|
||||||
|
}
|
||||||
107
remotedevice/src/ha_json.h
Normal file
107
remotedevice/src/ha_json.h
Normal file
@ -0,0 +1,107 @@
|
|||||||
|
#ifndef HA_JSON_H
|
||||||
|
#define HA_JSON_H
|
||||||
|
|
||||||
|
#include <stdint.h>
|
||||||
|
#include <stddef.h>
|
||||||
|
|
||||||
|
#ifdef __cplusplus
|
||||||
|
extern "C" {
|
||||||
|
#endif
|
||||||
|
|
||||||
|
/* ======================== JSON 解析器(DOM 风格) ======================== */
|
||||||
|
typedef enum {
|
||||||
|
HA_JSON_NULL,
|
||||||
|
HA_JSON_BOOL,
|
||||||
|
HA_JSON_INT,
|
||||||
|
HA_JSON_STRING,
|
||||||
|
HA_JSON_ARRAY,
|
||||||
|
HA_JSON_OBJECT,
|
||||||
|
} ha_json_type_t;
|
||||||
|
|
||||||
|
typedef struct ha_json_node {
|
||||||
|
ha_json_type_t type;
|
||||||
|
union {
|
||||||
|
int bool_val;
|
||||||
|
int int_val;
|
||||||
|
char *str_val;
|
||||||
|
};
|
||||||
|
struct ha_json_node *next; /* linked list for array/object items */
|
||||||
|
struct ha_json_node *child; /* first child for array/object */
|
||||||
|
char *key; /* key for object members */
|
||||||
|
} ha_json_node_t;
|
||||||
|
|
||||||
|
/* 解析 JSON 字符串,返回根节点。失败返回 NULL。 */
|
||||||
|
ha_json_node_t *ha_json_parse(const char *str);
|
||||||
|
|
||||||
|
/* 从对象中按 key 获取字符串值,不存在返回 NULL */
|
||||||
|
const char *ha_json_get_string(const ha_json_node_t *obj, const char *key);
|
||||||
|
|
||||||
|
/* 从对象中按 key 获取 int 值,不存在返回 def */
|
||||||
|
int ha_json_get_int(const ha_json_node_t *obj, const char *key, int def);
|
||||||
|
|
||||||
|
/* 从对象中按 key 获取子节点,不存在返回 NULL */
|
||||||
|
ha_json_node_t *ha_json_get(const ha_json_node_t *obj, const char *key);
|
||||||
|
|
||||||
|
/* 获取数组长度 */
|
||||||
|
int ha_json_array_len(const ha_json_node_t *arr);
|
||||||
|
|
||||||
|
/* 获取数组第 index 个元素,越界返回 NULL */
|
||||||
|
ha_json_node_t *ha_json_array_get(const ha_json_node_t *arr, int index);
|
||||||
|
|
||||||
|
/* 释放整个 JSON 树 */
|
||||||
|
void ha_json_free(ha_json_node_t *root);
|
||||||
|
|
||||||
|
/* ======================== JSON 构建器(直接写缓冲区) ======================== */
|
||||||
|
typedef struct {
|
||||||
|
char *buf;
|
||||||
|
int len;
|
||||||
|
int cap;
|
||||||
|
int depth;
|
||||||
|
int item_count[16]; /* 每层已添加元素数,用于逗号判断 */
|
||||||
|
} ha_json_builder_t;
|
||||||
|
|
||||||
|
/* 初始化构建器 */
|
||||||
|
void ha_json_builder_init(ha_json_builder_t *jb, char *buf, int cap);
|
||||||
|
|
||||||
|
/* 清空构建器 */
|
||||||
|
void ha_json_builder_reset(ha_json_builder_t *jb);
|
||||||
|
|
||||||
|
/* 基础写入 */
|
||||||
|
void ha_json_builder_raw(ha_json_builder_t *jb, const char *s);
|
||||||
|
|
||||||
|
/* 逗号(自动判断是否需要加) */
|
||||||
|
void ha_json_builder_comma(ha_json_builder_t *jb);
|
||||||
|
|
||||||
|
/* 对象 */
|
||||||
|
void ha_json_builder_begin_object(ha_json_builder_t *jb);
|
||||||
|
void ha_json_builder_end_object(ha_json_builder_t *jb);
|
||||||
|
|
||||||
|
/* 数组 */
|
||||||
|
void ha_json_builder_begin_array(ha_json_builder_t *jb);
|
||||||
|
void ha_json_builder_end_array(ha_json_builder_t *jb);
|
||||||
|
|
||||||
|
/* 键名 */
|
||||||
|
void ha_json_builder_key(ha_json_builder_t *jb, const char *key);
|
||||||
|
|
||||||
|
/* 值 */
|
||||||
|
void ha_json_builder_add_string(ha_json_builder_t *jb, const char *val);
|
||||||
|
void ha_json_builder_add_int(ha_json_builder_t *jb, int val);
|
||||||
|
void ha_json_builder_add_bool(ha_json_builder_t *jb, int val);
|
||||||
|
void ha_json_builder_add_null(ha_json_builder_t *jb);
|
||||||
|
|
||||||
|
/* 快捷方法:直接写 "key":"val" */
|
||||||
|
void ha_json_builder_string(ha_json_builder_t *jb, const char *key, const char *val);
|
||||||
|
void ha_json_builder_int(ha_json_builder_t *jb, const char *key, int val);
|
||||||
|
void ha_json_builder_bool(ha_json_builder_t *jb, const char *key, int val);
|
||||||
|
|
||||||
|
/* 获取当前构建的字符串指针 */
|
||||||
|
const char *ha_json_builder_str(ha_json_builder_t *jb);
|
||||||
|
|
||||||
|
/* 获取当前长度 */
|
||||||
|
int ha_json_builder_len(ha_json_builder_t *jb);
|
||||||
|
|
||||||
|
#ifdef __cplusplus
|
||||||
|
}
|
||||||
|
#endif
|
||||||
|
|
||||||
|
#endif /* HA_JSON_H */
|
||||||
628
remotedevice/src/ha_remotedevice.c
Normal file
628
remotedevice/src/ha_remotedevice.c
Normal file
@ -0,0 +1,628 @@
|
|||||||
|
#include "ha_remotedevice.h"
|
||||||
|
#include "ha_json.h"
|
||||||
|
#include "ha_ws.h"
|
||||||
|
#include <string.h>
|
||||||
|
#include <stdlib.h>
|
||||||
|
#include <stdio.h>
|
||||||
|
|
||||||
|
#define HA_VERSION "0.1.0"
|
||||||
|
|
||||||
|
/* 前向声明(因 handle_cmd_msg 需要调用这些函数,而它们定义在后面) */
|
||||||
|
void ha_client_send_result(ha_client_t *client, const char *req_id,
|
||||||
|
const char *status, const char *output,
|
||||||
|
const char *error);
|
||||||
|
void ha_client_send_data_chunked(ha_client_t *client, const char *req_id,
|
||||||
|
const char *kind, const char *mime,
|
||||||
|
const uint8_t *data, int len);
|
||||||
|
|
||||||
|
/* ======================== 内部状态 ======================== */
|
||||||
|
typedef enum {
|
||||||
|
HA_STATE_INIT,
|
||||||
|
HA_STATE_DISCONNECTED,
|
||||||
|
HA_STATE_CONNECTING,
|
||||||
|
HA_STATE_WS_UPGRADING,
|
||||||
|
HA_STATE_HELLO_SENT,
|
||||||
|
HA_STATE_BIND_SENT,
|
||||||
|
HA_STATE_READY,
|
||||||
|
HA_STATE_STOPPING,
|
||||||
|
} ha_state_t;
|
||||||
|
|
||||||
|
/* 语音数据聚合缓冲区 */
|
||||||
|
typedef struct {
|
||||||
|
char req_id[128];
|
||||||
|
char kind[64];
|
||||||
|
char mime[64];
|
||||||
|
int total;
|
||||||
|
uint8_t *data;
|
||||||
|
int len;
|
||||||
|
int cap;
|
||||||
|
} ha_speech_accum_t;
|
||||||
|
|
||||||
|
struct ha_client {
|
||||||
|
ha_config_t config; /* 拷贝的配置 */
|
||||||
|
ha_state_t state;
|
||||||
|
int reconnect_cnt; /* 当前重连次数 */
|
||||||
|
ha_ws_t ws; /* WS 连接 */
|
||||||
|
|
||||||
|
/* JSON 构建缓冲区 */
|
||||||
|
char json_buf[4096];
|
||||||
|
ha_json_builder_t jb;
|
||||||
|
|
||||||
|
/* 语音数据聚合 */
|
||||||
|
ha_speech_accum_t speech;
|
||||||
|
};
|
||||||
|
|
||||||
|
/* ======================== 辅助函数 ======================== */
|
||||||
|
|
||||||
|
static void set_sockbuf(ha_client_t *c, int i) { (void)c; (void)i; }
|
||||||
|
|
||||||
|
/* Base64 编码表 */
|
||||||
|
static const char b64[] = "ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789+/";
|
||||||
|
|
||||||
|
int ha_base64_encode(const uint8_t *data, int len, char *out, int out_len) {
|
||||||
|
int needed = ((len + 2) / 3) * 4 + 1;
|
||||||
|
if (out_len < needed) {
|
||||||
|
if (out_len > 0) out[0] = '\0';
|
||||||
|
return needed;
|
||||||
|
}
|
||||||
|
int i = 0, j = 0;
|
||||||
|
while (i < len) {
|
||||||
|
int rem = len - i;
|
||||||
|
uint8_t b0 = data[i++];
|
||||||
|
uint8_t b1 = (rem > 1) ? data[i++] : 0;
|
||||||
|
uint8_t b2 = (rem > 2) ? data[i++] : 0;
|
||||||
|
out[j++] = b64[b0 >> 2];
|
||||||
|
out[j++] = b64[((b0 & 0x03) << 4) | (b1 >> 4)];
|
||||||
|
out[j++] = (rem > 1) ? b64[((b1 & 0x0F) << 2) | (b2 >> 6)] : '=';
|
||||||
|
out[j++] = (rem > 2) ? b64[b2 & 0x3F] : '=';
|
||||||
|
}
|
||||||
|
out[j] = '\0';
|
||||||
|
return j;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ======================== JSON 构建辅助 ======================== */
|
||||||
|
static void json_init(ha_client_t *c) {
|
||||||
|
ha_json_builder_init(&c->jb, c->json_buf, sizeof(c->json_buf));
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ======================== WS 发送 JSON ======================== */
|
||||||
|
static int ws_send_json(ha_client_t *c) {
|
||||||
|
return ha_ws_send_text(&c->ws, c->json_buf);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ======================== 协议消息构造 ======================== */
|
||||||
|
|
||||||
|
/* 构建 hello 消息 */
|
||||||
|
static int send_hello(ha_client_t *c) {
|
||||||
|
json_init(c);
|
||||||
|
ha_json_builder_begin_object(&c->jb);
|
||||||
|
ha_json_builder_string(&c->jb, "op", "hello");
|
||||||
|
ha_json_builder_key(&c->jb, "device");
|
||||||
|
ha_json_builder_begin_object(&c->jb);
|
||||||
|
ha_json_builder_string(&c->jb, "device_id", c->config.device.device_id);
|
||||||
|
ha_json_builder_string(&c->jb, "name", c->config.device.name);
|
||||||
|
ha_json_builder_string(&c->jb, "kind", c->config.device.kind);
|
||||||
|
/* caps */
|
||||||
|
ha_json_builder_key(&c->jb, "caps");
|
||||||
|
ha_json_builder_begin_array(&c->jb);
|
||||||
|
if (c->config.device.caps) {
|
||||||
|
for (const char **p = c->config.device.caps; *p; p++) {
|
||||||
|
ha_json_builder_add_string(&c->jb, *p);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
ha_json_builder_end_array(&c->jb);
|
||||||
|
/* info 可选 */
|
||||||
|
if (c->config.device.info_json && c->config.device.info_json[0]) {
|
||||||
|
ha_json_builder_string(&c->jb, "info", c->config.device.info_json);
|
||||||
|
}
|
||||||
|
ha_json_builder_end_object(&c->jb); /* device */
|
||||||
|
ha_json_builder_end_object(&c->jb); /* root */
|
||||||
|
return ws_send_json(c);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* 构建 bind 消息 */
|
||||||
|
static int send_bind(ha_client_t *c) {
|
||||||
|
json_init(c);
|
||||||
|
ha_json_builder_begin_object(&c->jb);
|
||||||
|
ha_json_builder_string(&c->jb, "op", "bind");
|
||||||
|
ha_json_builder_string(&c->jb, "device_id", c->config.device.device_id);
|
||||||
|
ha_json_builder_string(&c->jb, "token", c->config.token);
|
||||||
|
ha_json_builder_end_object(&c->jb);
|
||||||
|
return ws_send_json(c);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ======================== 消息处理 ======================== */
|
||||||
|
|
||||||
|
/* 在 handlers 表中查找命令处理函数 */
|
||||||
|
static ha_cmd_handler_def_t *find_handler(ha_client_t *c, const char *name) {
|
||||||
|
if (!name || !c->config.handlers) return NULL;
|
||||||
|
for (ha_cmd_handler_def_t *h = c->config.handlers; h->command; h++) {
|
||||||
|
if (strcmp(h->command, name) == 0) return h;
|
||||||
|
}
|
||||||
|
return NULL;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* 声明式命令分发:查找 handlers 表 → 调用 handler → 自动回执 */
|
||||||
|
static void handle_cmd_msg(ha_client_t *c, ha_json_node_t *msg) {
|
||||||
|
const char *req_id = ha_json_get_string(msg, "req_id");
|
||||||
|
const char *command = ha_json_get_string(msg, "command");
|
||||||
|
const char *cmd_type = ha_json_get_string(msg, "cmd_type");
|
||||||
|
if (!req_id || !command) return;
|
||||||
|
if (!cmd_type) cmd_type = "homeagent";
|
||||||
|
|
||||||
|
const char *handler_name = NULL;
|
||||||
|
const char *args = command;
|
||||||
|
|
||||||
|
if (strcmp(cmd_type, "shell") == 0) {
|
||||||
|
handler_name = "shell";
|
||||||
|
/* args 保持为完整命令字符串 */
|
||||||
|
} else {
|
||||||
|
/* homeagent-* 命令:提取能力名作为 handler 名 */
|
||||||
|
const char *cap = command;
|
||||||
|
const char *p = command;
|
||||||
|
if (strncmp(p, "homeagent-", 10) == 0) p += 10;
|
||||||
|
const char *space = strchr(p, ' ');
|
||||||
|
if (space) {
|
||||||
|
args = space + 1;
|
||||||
|
/* handler_name 用静态缓冲区 */
|
||||||
|
static char name_buf[128];
|
||||||
|
int n = (int)(space - p);
|
||||||
|
if (n > 127) n = 127;
|
||||||
|
strncpy(name_buf, p, n);
|
||||||
|
name_buf[n] = '\0';
|
||||||
|
handler_name = name_buf;
|
||||||
|
} else {
|
||||||
|
handler_name = p;
|
||||||
|
args = "";
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
ha_cmd_handler_def_t *def = find_handler(c, handler_name);
|
||||||
|
if (!def) {
|
||||||
|
ha_client_send_result(c, req_id, "error", NULL,
|
||||||
|
"unsupported command");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* 调用 handler,填写 result */
|
||||||
|
ha_cmd_result_t result;
|
||||||
|
memset(&result, 0, sizeof(result));
|
||||||
|
ha_status_t st = def->handler(req_id, args, &result, c->config.userdata);
|
||||||
|
|
||||||
|
/* 自动回执 */
|
||||||
|
if (st != HA_OK) {
|
||||||
|
ha_client_send_result(c, req_id, "error", NULL,
|
||||||
|
result.error ? result.error : "handler failed");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (result.has_binary && result.binary_data && result.binary_len > 0) {
|
||||||
|
/* 二进制分块回传 */
|
||||||
|
ha_client_send_data_chunked(c, req_id,
|
||||||
|
handler_name, result.binary_mime ? result.binary_mime : "application/octet-stream",
|
||||||
|
result.binary_data, result.binary_len);
|
||||||
|
} else {
|
||||||
|
/* 文本回传 */
|
||||||
|
ha_client_send_result(c, req_id, result.status == 0 ? "ok" : "error",
|
||||||
|
result.output, result.error);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
static void handle_speech_start(ha_client_t *c, ha_json_node_t *msg) {
|
||||||
|
const char *req_id = ha_json_get_string(msg, "req_id");
|
||||||
|
const char *kind = ha_json_get_string(msg, "kind");
|
||||||
|
const char *mime = ha_json_get_string(msg, "mime");
|
||||||
|
if (!req_id) return;
|
||||||
|
|
||||||
|
/* 释放旧的聚合数据 */
|
||||||
|
free(c->speech.data);
|
||||||
|
memset(&c->speech, 0, sizeof(c->speech));
|
||||||
|
|
||||||
|
strncpy(c->speech.req_id, req_id, sizeof(c->speech.req_id) - 1);
|
||||||
|
if (kind) strncpy(c->speech.kind, kind, sizeof(c->speech.kind) - 1);
|
||||||
|
if (mime) strncpy(c->speech.mime, mime, sizeof(c->speech.mime) - 1);
|
||||||
|
c->speech.total = ha_json_get_int(msg, "total", 0);
|
||||||
|
}
|
||||||
|
|
||||||
|
static void handle_speech_end(ha_client_t *c, ha_json_node_t *msg) {
|
||||||
|
const char *req_id = ha_json_get_string(msg, "req_id");
|
||||||
|
if (!req_id || strcmp(req_id, c->speech.req_id) != 0) return;
|
||||||
|
|
||||||
|
if (c->config.on_binary && c->speech.data && c->speech.len > 0) {
|
||||||
|
c->config.on_binary(c->speech.req_id, c->speech.kind,
|
||||||
|
c->speech.mime, c->speech.data,
|
||||||
|
c->speech.len, c->config.userdata);
|
||||||
|
}
|
||||||
|
|
||||||
|
free(c->speech.data);
|
||||||
|
memset(&c->speech, 0, sizeof(c->speech));
|
||||||
|
}
|
||||||
|
|
||||||
|
static void handle_text_message(ha_client_t *c, const uint8_t *payload, int len) {
|
||||||
|
/* 解析 JSON */
|
||||||
|
char *tmp = (char *)malloc(len + 1);
|
||||||
|
if (!tmp) return;
|
||||||
|
memcpy(tmp, payload, len);
|
||||||
|
tmp[len] = '\0';
|
||||||
|
|
||||||
|
ha_json_node_t *root = ha_json_parse(tmp);
|
||||||
|
if (!root) { free(tmp); return; }
|
||||||
|
|
||||||
|
const char *op = ha_json_get_string(root, "op");
|
||||||
|
if (!op) { ha_json_free(root); free(tmp); return; }
|
||||||
|
|
||||||
|
switch (c->state) {
|
||||||
|
case HA_STATE_HELLO_SENT:
|
||||||
|
if (strcmp(op, "hello_ack") == 0) {
|
||||||
|
c->state = HA_STATE_BIND_SENT;
|
||||||
|
send_bind(c);
|
||||||
|
}
|
||||||
|
break;
|
||||||
|
case HA_STATE_BIND_SENT:
|
||||||
|
if (strcmp(op, "bind_ack") == 0) {
|
||||||
|
c->state = HA_STATE_READY;
|
||||||
|
if (c->config.on_state) {
|
||||||
|
c->config.on_state(1, c->config.userdata);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
break;
|
||||||
|
case HA_STATE_READY:
|
||||||
|
if (strcmp(op, "cmd") == 0) {
|
||||||
|
handle_cmd_msg(c, root);
|
||||||
|
} else if (strcmp(op, "cmd_speech_start") == 0) {
|
||||||
|
handle_speech_start(c, root);
|
||||||
|
} else if (strcmp(op, "cmd_speech_end") == 0) {
|
||||||
|
handle_speech_end(c, root);
|
||||||
|
}
|
||||||
|
break;
|
||||||
|
default:
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
|
||||||
|
ha_json_free(root);
|
||||||
|
free(tmp);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ======================== 连接管理 ======================== */
|
||||||
|
|
||||||
|
static int do_connect(ha_client_t *c) {
|
||||||
|
c->state = HA_STATE_CONNECTING;
|
||||||
|
c->reconnect_cnt++;
|
||||||
|
|
||||||
|
/* 解析 server 地址 */
|
||||||
|
char host[256] = {0};
|
||||||
|
uint16_t port = 9890;
|
||||||
|
const char *p = c->config.server;
|
||||||
|
if (!p) return -1;
|
||||||
|
|
||||||
|
/* 去掉 ws:// 前缀 */
|
||||||
|
if (strncmp(p, "ws://", 5) == 0) p += 5;
|
||||||
|
else if (strncmp(p, "wss://", 6) == 0) p += 6;
|
||||||
|
|
||||||
|
/* 提取 host:port */
|
||||||
|
const char *colon = strchr(p, ':');
|
||||||
|
const char *slash = strchr(p, '/');
|
||||||
|
if (colon && (!slash || colon < slash)) {
|
||||||
|
int host_len = (int)(colon - p);
|
||||||
|
if (host_len > (int)sizeof(host) - 1) host_len = sizeof(host) - 1;
|
||||||
|
memcpy(host, p, host_len);
|
||||||
|
host[host_len] = '\0';
|
||||||
|
port = (uint16_t)atoi(colon + 1);
|
||||||
|
} else {
|
||||||
|
int host_len = (slash ? (int)(slash - p) : (int)strlen(p));
|
||||||
|
if (host_len > (int)sizeof(host) - 1) host_len = sizeof(host) - 1;
|
||||||
|
memcpy(host, p, host_len);
|
||||||
|
host[host_len] = '\0';
|
||||||
|
}
|
||||||
|
|
||||||
|
c->state = HA_STATE_WS_UPGRADING;
|
||||||
|
if (ha_ws_connect(&c->ws, &c->config.transport, host, port,
|
||||||
|
"/api/v1/device/ws", c->config.token) != 0) {
|
||||||
|
c->state = HA_STATE_DISCONNECTED;
|
||||||
|
return -1;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* 发送 hello */
|
||||||
|
c->state = HA_STATE_HELLO_SENT;
|
||||||
|
if (send_hello(c) != 0) {
|
||||||
|
ha_ws_close(&c->ws);
|
||||||
|
c->state = HA_STATE_DISCONNECTED;
|
||||||
|
return -1;
|
||||||
|
}
|
||||||
|
|
||||||
|
return 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ======================== 公共 API ======================== */
|
||||||
|
|
||||||
|
ha_client_t *ha_client_new(const ha_config_t *config) {
|
||||||
|
ha_client_t *c = (ha_client_t *)calloc(1, sizeof(ha_client_t));
|
||||||
|
if (!c) return NULL;
|
||||||
|
memcpy(&c->config, config, sizeof(ha_config_t));
|
||||||
|
c->state = HA_STATE_INIT;
|
||||||
|
c->reconnect_cnt = 0;
|
||||||
|
return c;
|
||||||
|
}
|
||||||
|
|
||||||
|
ha_status_t ha_client_start(ha_client_t *client) {
|
||||||
|
if (!client) return HA_ERR_INVALID;
|
||||||
|
if (client->state != HA_STATE_INIT) return HA_ERR_GENERIC;
|
||||||
|
|
||||||
|
/* 默认心跳间隔 30 秒 */
|
||||||
|
if (client->config.ping_interval <= 0) {
|
||||||
|
client->config.ping_interval = 30;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (do_connect(client) != 0) {
|
||||||
|
return HA_ERR_TRANSPORT;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* 等待 bind_ack(最多 5 秒) */
|
||||||
|
int wait_ms = 5000;
|
||||||
|
int step = 50;
|
||||||
|
while (wait_ms > 0 && client->state != HA_STATE_READY) {
|
||||||
|
/* 处理一帧 */
|
||||||
|
ha_status_t st = ha_client_process(client);
|
||||||
|
if (st != HA_OK && st != HA_ERR_DISCONNECTED) {
|
||||||
|
return st;
|
||||||
|
}
|
||||||
|
if (client->state == HA_STATE_READY) return HA_OK;
|
||||||
|
|
||||||
|
/* 简单延时:靠 process 中的 recv 阻塞 */
|
||||||
|
wait_ms -= step;
|
||||||
|
}
|
||||||
|
|
||||||
|
return (client->state == HA_STATE_READY) ? HA_OK : HA_ERR_TIMEOUT;
|
||||||
|
}
|
||||||
|
|
||||||
|
ha_status_t ha_client_process(ha_client_t *client) {
|
||||||
|
if (!client) return HA_ERR_INVALID;
|
||||||
|
|
||||||
|
if (client->state == HA_STATE_STOPPING) {
|
||||||
|
return HA_ERR_DISCONNECTED;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* 断线重连 */
|
||||||
|
if (client->state == HA_STATE_DISCONNECTED ||
|
||||||
|
client->state == HA_STATE_INIT) {
|
||||||
|
if (client->config.max_reconnect >= 0 &&
|
||||||
|
client->reconnect_cnt > client->config.max_reconnect) {
|
||||||
|
return HA_ERR_DISCONNECTED;
|
||||||
|
}
|
||||||
|
/* 非阻塞模式:不在这里阻塞等待重连,返回 HA_ERR_DISCONNECTED */
|
||||||
|
return HA_ERR_DISCONNECTED;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!client->ws.connected) {
|
||||||
|
client->state = HA_STATE_DISCONNECTED;
|
||||||
|
if (client->config.on_state) {
|
||||||
|
client->config.on_state(0, client->config.userdata);
|
||||||
|
}
|
||||||
|
return HA_ERR_DISCONNECTED;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* 尝试读取一帧 */
|
||||||
|
const uint8_t *payload = NULL;
|
||||||
|
int len = 0;
|
||||||
|
int ret = ha_ws_read_frame(&client->ws, &payload, &len);
|
||||||
|
|
||||||
|
if (ret < 0) {
|
||||||
|
/* 连接断开 */
|
||||||
|
client->state = HA_STATE_DISCONNECTED;
|
||||||
|
if (client->config.on_state) {
|
||||||
|
client->config.on_state(0, client->config.userdata);
|
||||||
|
}
|
||||||
|
return HA_ERR_DISCONNECTED;
|
||||||
|
}
|
||||||
|
|
||||||
|
switch (ret) {
|
||||||
|
case WS_OPCODE_TEXT:
|
||||||
|
handle_text_message(client, payload, len);
|
||||||
|
break;
|
||||||
|
case WS_OPCODE_BINARY:
|
||||||
|
/* 二进制帧:如果处于语音聚合状态,追加数据 */
|
||||||
|
if (client->speech.req_id[0] && payload) {
|
||||||
|
int new_len = client->speech.len + len;
|
||||||
|
if (new_len > client->speech.cap) {
|
||||||
|
int new_cap = client->speech.cap ? client->speech.cap * 2 : 4096;
|
||||||
|
while (new_cap < new_len) new_cap *= 2;
|
||||||
|
uint8_t *nd = (uint8_t *)realloc(client->speech.data, new_cap);
|
||||||
|
if (!nd) break;
|
||||||
|
client->speech.data = nd;
|
||||||
|
client->speech.cap = new_cap;
|
||||||
|
}
|
||||||
|
memcpy(client->speech.data + client->speech.len, payload, len);
|
||||||
|
client->speech.len = new_len;
|
||||||
|
}
|
||||||
|
break;
|
||||||
|
case WS_OPCODE_PING:
|
||||||
|
/* 回复 pong */
|
||||||
|
ha_ws_send_frame(&client->ws, WS_OPCODE_PONG, NULL, 0);
|
||||||
|
break;
|
||||||
|
case WS_OPCODE_PONG:
|
||||||
|
/* 收到 pong,忽略 */
|
||||||
|
break;
|
||||||
|
case WS_OPCODE_CLOSE:
|
||||||
|
client->state = HA_STATE_DISCONNECTED;
|
||||||
|
if (client->config.on_state) {
|
||||||
|
client->config.on_state(0, client->config.userdata);
|
||||||
|
}
|
||||||
|
return HA_ERR_DISCONNECTED;
|
||||||
|
}
|
||||||
|
|
||||||
|
return HA_OK;
|
||||||
|
}
|
||||||
|
|
||||||
|
void ha_client_send_result(ha_client_t *client, const char *req_id,
|
||||||
|
const char *status, const char *output,
|
||||||
|
const char *error) {
|
||||||
|
if (!client || client->state != HA_STATE_READY) return;
|
||||||
|
json_init(client);
|
||||||
|
ha_json_builder_begin_object(&client->jb);
|
||||||
|
ha_json_builder_string(&client->jb, "op", "cmd_result");
|
||||||
|
ha_json_builder_string(&client->jb, "req_id", req_id);
|
||||||
|
ha_json_builder_string(&client->jb, "status", status ? status : "ok");
|
||||||
|
ha_json_builder_string(&client->jb, "device_id", client->config.device.device_id);
|
||||||
|
if (output && output[0]) {
|
||||||
|
ha_json_builder_string(&client->jb, "output", output);
|
||||||
|
}
|
||||||
|
if (error && error[0]) {
|
||||||
|
ha_json_builder_string(&client->jb, "error", error);
|
||||||
|
}
|
||||||
|
ha_json_builder_end_object(&client->jb);
|
||||||
|
ws_send_json(client);
|
||||||
|
}
|
||||||
|
|
||||||
|
void ha_client_send_data_chunked(ha_client_t *client, const char *req_id,
|
||||||
|
const char *kind, const char *mime,
|
||||||
|
const uint8_t *data, int len) {
|
||||||
|
if (!client || client->state != HA_STATE_READY) return;
|
||||||
|
|
||||||
|
/* cmd_data_start */
|
||||||
|
json_init(client);
|
||||||
|
ha_json_builder_begin_object(&client->jb);
|
||||||
|
ha_json_builder_string(&client->jb, "op", "cmd_data_start");
|
||||||
|
ha_json_builder_string(&client->jb, "req_id", req_id);
|
||||||
|
ha_json_builder_string(&client->jb, "kind", kind ? kind : "data");
|
||||||
|
ha_json_builder_string(&client->jb, "mime", mime ? mime : "application/octet-stream");
|
||||||
|
ha_json_builder_int(&client->jb, "total", len);
|
||||||
|
ha_json_builder_int(&client->jb, "chunk_size", 8192);
|
||||||
|
ha_json_builder_end_object(&client->jb);
|
||||||
|
ws_send_json(client);
|
||||||
|
|
||||||
|
/* 二进制帧分块发送 */
|
||||||
|
int off = 0;
|
||||||
|
while (off < len) {
|
||||||
|
int chunk = len - off;
|
||||||
|
if (chunk > 8192) chunk = 8192;
|
||||||
|
if (ha_ws_send_binary(&client->ws, data + off, chunk) != 0) return;
|
||||||
|
off += chunk;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* cmd_data_end */
|
||||||
|
json_init(client);
|
||||||
|
ha_json_builder_begin_object(&client->jb);
|
||||||
|
ha_json_builder_string(&client->jb, "op", "cmd_data_end");
|
||||||
|
ha_json_builder_string(&client->jb, "req_id", req_id);
|
||||||
|
ha_json_builder_string(&client->jb, "status", "ok");
|
||||||
|
ha_json_builder_int(&client->jb, "total", len);
|
||||||
|
ha_json_builder_end_object(&client->jb);
|
||||||
|
ws_send_json(client);
|
||||||
|
}
|
||||||
|
|
||||||
|
void ha_client_send_event(ha_client_t *client, const char *type,
|
||||||
|
const char *detail) {
|
||||||
|
if (!client || client->state != HA_STATE_READY) return;
|
||||||
|
json_init(client);
|
||||||
|
ha_json_builder_begin_object(&client->jb);
|
||||||
|
ha_json_builder_string(&client->jb, "op", "event");
|
||||||
|
ha_json_builder_string(&client->jb, "device_id", client->config.device.device_id);
|
||||||
|
ha_json_builder_string(&client->jb, "type", type ? type : "");
|
||||||
|
if (detail && detail[0]) {
|
||||||
|
ha_json_builder_string(&client->jb, "payload", detail);
|
||||||
|
}
|
||||||
|
ha_json_builder_end_object(&client->jb);
|
||||||
|
ws_send_json(client);
|
||||||
|
}
|
||||||
|
|
||||||
|
void ha_client_send_status(ha_client_t *client, const char *status) {
|
||||||
|
if (!client || client->state != HA_STATE_READY) return;
|
||||||
|
json_init(client);
|
||||||
|
ha_json_builder_begin_object(&client->jb);
|
||||||
|
ha_json_builder_string(&client->jb, "op", "status");
|
||||||
|
ha_json_builder_string(&client->jb, "device_id", client->config.device.device_id);
|
||||||
|
ha_json_builder_string(&client->jb, "status", status ? status : "online");
|
||||||
|
ha_json_builder_end_object(&client->jb);
|
||||||
|
ws_send_json(client);
|
||||||
|
}
|
||||||
|
|
||||||
|
void ha_client_stop(ha_client_t *client) {
|
||||||
|
if (!client) return;
|
||||||
|
client->state = HA_STATE_STOPPING;
|
||||||
|
if (client->ws.connected) {
|
||||||
|
ha_ws_close(&client->ws);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
void ha_client_destroy(ha_client_t *client) {
|
||||||
|
if (!client) return;
|
||||||
|
ha_client_stop(client);
|
||||||
|
free(client->speech.data);
|
||||||
|
free(client);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ======================== 工具函数 ======================== */
|
||||||
|
|
||||||
|
void ha_cmd_parse_homeagent(const char *command, const char **cap,
|
||||||
|
const char **args) {
|
||||||
|
*cap = command;
|
||||||
|
*args = "";
|
||||||
|
|
||||||
|
if (!command) {
|
||||||
|
*cap = "";
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* 去掉 homeagent- 前缀 */
|
||||||
|
const char *p = command;
|
||||||
|
if (strncmp(p, "homeagent-", 10) == 0) {
|
||||||
|
p += 10;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* 按空格分割 */
|
||||||
|
const char *space = strchr(p, ' ');
|
||||||
|
if (space) {
|
||||||
|
/* cap 指向 p 但不包含空格,需要临时拷贝 */
|
||||||
|
/* 返回指针到原始字符串,调用方用 strncpy 取出 */
|
||||||
|
*cap = command; /* 调用方应使用 ha_cmd_parse_homeagent 的要小心 */
|
||||||
|
/* 实际上,最简单的方式是原地修改,但 const 不允许 */
|
||||||
|
/* 用静态缓冲区或让调用方自己处理 */
|
||||||
|
static char cap_buf[256];
|
||||||
|
int n = (int)(space - p);
|
||||||
|
if (n > 255) n = 255;
|
||||||
|
strncpy(cap_buf, p, n);
|
||||||
|
cap_buf[n] = '\0';
|
||||||
|
*cap = cap_buf;
|
||||||
|
*args = space + 1;
|
||||||
|
} else {
|
||||||
|
static char cap_buf[256];
|
||||||
|
strncpy(cap_buf, p, sizeof(cap_buf) - 1);
|
||||||
|
cap_buf[sizeof(cap_buf) - 1] = '\0';
|
||||||
|
*cap = cap_buf;
|
||||||
|
*args = "";
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
void ha_cmd_parse_json(const char *command, const char **action,
|
||||||
|
const char **json_str) {
|
||||||
|
*action = "";
|
||||||
|
*json_str = "";
|
||||||
|
|
||||||
|
if (!command) return;
|
||||||
|
|
||||||
|
const char *p = command;
|
||||||
|
if (strncmp(p, "homeagent-", 10) == 0) {
|
||||||
|
p += 10;
|
||||||
|
}
|
||||||
|
|
||||||
|
const char *brace = strchr(p, '{');
|
||||||
|
if (brace) {
|
||||||
|
static char act_buf[256];
|
||||||
|
int n = (int)(brace - p);
|
||||||
|
while (n > 0 && (p[n - 1] == ' ' || p[n - 1] == '\t')) n--;
|
||||||
|
if (n > 255) n = 255;
|
||||||
|
strncpy(act_buf, p, n);
|
||||||
|
act_buf[n] = '\0';
|
||||||
|
*action = act_buf;
|
||||||
|
*json_str = brace;
|
||||||
|
} else {
|
||||||
|
static char act_buf[256];
|
||||||
|
strncpy(act_buf, p, sizeof(act_buf) - 1);
|
||||||
|
*action = act_buf;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const char *ha_version(void) {
|
||||||
|
return HA_VERSION;
|
||||||
|
}
|
||||||
|
|
||||||
325
remotedevice/src/ha_ws.c
Normal file
325
remotedevice/src/ha_ws.c
Normal file
@ -0,0 +1,325 @@
|
|||||||
|
#include "ha_ws.h"
|
||||||
|
#include <string.h>
|
||||||
|
#include <stdio.h>
|
||||||
|
#include <stdlib.h>
|
||||||
|
|
||||||
|
/* WS GUID 用于计算 Accept 值 */
|
||||||
|
#define WS_GUID "258EAFA5-E914-47DA-95CA-C5AB0DC85B11"
|
||||||
|
|
||||||
|
/* ======================== Base64 编码(用于 WS key) ======================== */
|
||||||
|
static const char b64t[] = "ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789+/";
|
||||||
|
|
||||||
|
static void base64_encode_bin(const uint8_t *in, int in_len, char *out) {
|
||||||
|
int i = 0, j = 0;
|
||||||
|
uint8_t b[3];
|
||||||
|
while (i < in_len) {
|
||||||
|
int rem = in_len - i;
|
||||||
|
if (rem >= 3) {
|
||||||
|
b[0] = in[i++]; b[1] = in[i++]; b[2] = in[i++];
|
||||||
|
out[j++] = b64t[b[0] >> 2];
|
||||||
|
out[j++] = b64t[((b[0] & 0x03) << 4) | (b[1] >> 4)];
|
||||||
|
out[j++] = b64t[((b[1] & 0x0F) << 2) | (b[2] >> 6)];
|
||||||
|
out[j++] = b64t[b[2] & 0x3F];
|
||||||
|
} else if (rem == 2) {
|
||||||
|
b[0] = in[i++]; b[1] = in[i++];
|
||||||
|
out[j++] = b64t[b[0] >> 2];
|
||||||
|
out[j++] = b64t[((b[0] & 0x03) << 4) | (b[1] >> 4)];
|
||||||
|
out[j++] = b64t[(b[1] & 0x0F) << 2];
|
||||||
|
out[j++] = '=';
|
||||||
|
} else {
|
||||||
|
b[0] = in[i++];
|
||||||
|
out[j++] = b64t[b[0] >> 2];
|
||||||
|
out[j++] = b64t[(b[0] & 0x03) << 4];
|
||||||
|
out[j++] = '=';
|
||||||
|
out[j++] = '=';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
out[j] = '\0';
|
||||||
|
}
|
||||||
|
|
||||||
|
/* 简单伪随机数生成器 */
|
||||||
|
static uint32_t ws_rand_state = 0;
|
||||||
|
static void ws_rand_seed(uint32_t seed) { ws_rand_state = seed; }
|
||||||
|
static uint32_t ws_rand(void) {
|
||||||
|
ws_rand_state = ws_rand_state * 1103515245 + 12345;
|
||||||
|
return ws_rand_state;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* 生成 WS 握手 key */
|
||||||
|
static void ws_gen_key(char *out) {
|
||||||
|
uint8_t buf[16];
|
||||||
|
for (int i = 0; i < 16; i++) {
|
||||||
|
buf[i] = (uint8_t)(ws_rand() & 0xFF);
|
||||||
|
}
|
||||||
|
base64_encode_bin(buf, 16, out);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ======================== 从传输层接收指定字节数 ======================== */
|
||||||
|
static int recv_all(ha_ws_t *ws, uint8_t *buf, int len) {
|
||||||
|
int pos = 0;
|
||||||
|
while (pos < len) {
|
||||||
|
int n = ws->transport->recv(ws->transport->ctx, buf + pos, len - pos);
|
||||||
|
if (n <= 0) return -1;
|
||||||
|
pos += n;
|
||||||
|
}
|
||||||
|
return 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ======================== 发送 WS 帧 ======================== */
|
||||||
|
int ha_ws_send_frame(ha_ws_t *ws, int opcode, const uint8_t *payload, int len) {
|
||||||
|
uint8_t hdr[14]; /* 最大帧头:2 + 8 + 4 = 14 */
|
||||||
|
int hdr_len = 0;
|
||||||
|
|
||||||
|
hdr[0] = 0x80 | opcode; /* FIN + opcode */
|
||||||
|
hdr_len = 2;
|
||||||
|
|
||||||
|
int ext_len = 0;
|
||||||
|
if (len < 126) {
|
||||||
|
hdr[1] = 0x80 | len; /* mask bit + length */
|
||||||
|
} else if (len < 65536) {
|
||||||
|
hdr[1] = 0x80 | 126;
|
||||||
|
hdr_len = 4;
|
||||||
|
hdr[2] = (uint8_t)(len >> 8);
|
||||||
|
hdr[3] = (uint8_t)(len & 0xFF);
|
||||||
|
ext_len = 2;
|
||||||
|
} else {
|
||||||
|
hdr[1] = 0x80 | 127;
|
||||||
|
hdr_len = 10;
|
||||||
|
uint64_t l = (uint64_t)len;
|
||||||
|
for (int i = 8; i > 0; i--) {
|
||||||
|
hdr[1 + i] = (uint8_t)(l & 0xFF);
|
||||||
|
l >>= 8;
|
||||||
|
}
|
||||||
|
ext_len = 8;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* mask key */
|
||||||
|
uint8_t mask_key[4];
|
||||||
|
mask_key[0] = (uint8_t)(ws_rand() & 0xFF);
|
||||||
|
mask_key[1] = (uint8_t)(ws_rand() & 0xFF);
|
||||||
|
mask_key[2] = (uint8_t)(ws_rand() & 0xFF);
|
||||||
|
mask_key[3] = (uint8_t)(ws_rand() & 0xFF);
|
||||||
|
|
||||||
|
int mask_off = 2 + ext_len;
|
||||||
|
hdr[mask_off] = mask_key[0];
|
||||||
|
hdr[mask_off + 1] = mask_key[1];
|
||||||
|
hdr[mask_off + 2] = mask_key[2];
|
||||||
|
hdr[mask_off + 3] = mask_key[3];
|
||||||
|
hdr_len = mask_off + 4;
|
||||||
|
|
||||||
|
/* 发送帧头 */
|
||||||
|
if (ws->transport->send(ws->transport->ctx, hdr, hdr_len) != hdr_len) {
|
||||||
|
return -1;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* 发送掩码后的 payload */
|
||||||
|
if (len > 0) {
|
||||||
|
/* 如果 payload 不大,用栈缓冲区 */
|
||||||
|
uint8_t stack_buf[2048];
|
||||||
|
uint8_t *masked = (len <= (int)sizeof(stack_buf)) ? stack_buf : (uint8_t *)malloc(len);
|
||||||
|
if (!masked) return -1;
|
||||||
|
|
||||||
|
for (int i = 0; i < len; i++) {
|
||||||
|
masked[i] = payload[i] ^ mask_key[i & 3];
|
||||||
|
}
|
||||||
|
|
||||||
|
int ret = (ws->transport->send(ws->transport->ctx, masked, len) == len) ? 0 : -1;
|
||||||
|
|
||||||
|
if (masked != stack_buf) free(masked);
|
||||||
|
if (ret != 0) return -1;
|
||||||
|
}
|
||||||
|
|
||||||
|
return 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ======================== 公共 API ======================== */
|
||||||
|
|
||||||
|
int ha_ws_connect(ha_ws_t *ws, ha_transport_t *transport,
|
||||||
|
const char *host, uint16_t port,
|
||||||
|
const char *path, const char *token) {
|
||||||
|
memset(ws, 0, sizeof(ha_ws_t));
|
||||||
|
ws->transport = transport;
|
||||||
|
ws->connected = 0;
|
||||||
|
|
||||||
|
strncpy(ws->host, host, sizeof(ws->host) - 1);
|
||||||
|
ws->port = port;
|
||||||
|
strncpy(ws->path, path, sizeof(ws->path) - 1);
|
||||||
|
if (token) strncpy(ws->token, token, sizeof(ws->token) - 1);
|
||||||
|
|
||||||
|
/* 种子 */
|
||||||
|
ws_rand_seed((uint32_t)(uintptr_t)ws ^ (uint32_t)port);
|
||||||
|
|
||||||
|
/* 1. TCP 连接 */
|
||||||
|
if (transport->connect(transport->ctx, host, port) != 0) {
|
||||||
|
return -1;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* 2. 发送 WS 升级请求 */
|
||||||
|
char key[32];
|
||||||
|
ws_gen_key(key);
|
||||||
|
|
||||||
|
char req[1024];
|
||||||
|
int n = snprintf(req, sizeof(req),
|
||||||
|
"GET %s HTTP/1.1\r\n"
|
||||||
|
"Host: %s:%u\r\n"
|
||||||
|
"Upgrade: websocket\r\n"
|
||||||
|
"Connection: Upgrade\r\n"
|
||||||
|
"Sec-WebSocket-Key: %s\r\n"
|
||||||
|
"Sec-WebSocket-Version: 13\r\n"
|
||||||
|
"\r\n",
|
||||||
|
path, host, (unsigned)port, key);
|
||||||
|
|
||||||
|
/* 如果 token 存在,加到路径参数中 */
|
||||||
|
if (token && token[0]) {
|
||||||
|
n = snprintf(req, sizeof(req),
|
||||||
|
"GET %s?token=%s HTTP/1.1\r\n"
|
||||||
|
"Host: %s:%u\r\n"
|
||||||
|
"Upgrade: websocket\r\n"
|
||||||
|
"Connection: Upgrade\r\n"
|
||||||
|
"Sec-WebSocket-Key: %s\r\n"
|
||||||
|
"Sec-WebSocket-Version: 13\r\n"
|
||||||
|
"\r\n",
|
||||||
|
path, token, host, (unsigned)port, key);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (transport->send(transport->ctx, (uint8_t *)req, n) != n) {
|
||||||
|
transport->close(transport->ctx);
|
||||||
|
return -1;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* 3. 读取响应头(直到 \r\n\r\n) */
|
||||||
|
char resp[1024];
|
||||||
|
int resp_len = 0;
|
||||||
|
int found = 0;
|
||||||
|
while (resp_len < (int)sizeof(resp) - 1) {
|
||||||
|
int n = transport->recv(transport->ctx, (uint8_t *)(resp + resp_len), 1);
|
||||||
|
if (n <= 0) {
|
||||||
|
transport->close(transport->ctx);
|
||||||
|
return -1;
|
||||||
|
}
|
||||||
|
resp_len += n;
|
||||||
|
resp[resp_len] = '\0';
|
||||||
|
if (resp_len >= 4 && strcmp(resp + resp_len - 4, "\r\n\r\n") == 0) {
|
||||||
|
found = 1;
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (!found) {
|
||||||
|
transport->close(transport->ctx);
|
||||||
|
return -1;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* 4. 检查状态码 101 */
|
||||||
|
if (strstr(resp, " 101 ") == NULL) {
|
||||||
|
transport->close(transport->ctx);
|
||||||
|
return -1;
|
||||||
|
}
|
||||||
|
|
||||||
|
ws->connected = 1;
|
||||||
|
return 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
int ha_ws_send_text(ha_ws_t *ws, const char *text) {
|
||||||
|
if (!ws->connected) return -1;
|
||||||
|
return ha_ws_send_frame(ws, WS_OPCODE_TEXT, (const uint8_t *)text, (int)strlen(text));
|
||||||
|
}
|
||||||
|
|
||||||
|
int ha_ws_send_binary(ha_ws_t *ws, const uint8_t *data, int len) {
|
||||||
|
if (!ws->connected) return -1;
|
||||||
|
return ha_ws_send_frame(ws, WS_OPCODE_BINARY, data, len);
|
||||||
|
}
|
||||||
|
|
||||||
|
int ha_ws_send_ping(ha_ws_t *ws) {
|
||||||
|
if (!ws->connected) return -1;
|
||||||
|
return ha_ws_send_frame(ws, WS_OPCODE_PING, NULL, 0);
|
||||||
|
}
|
||||||
|
|
||||||
|
int ha_ws_read_frame(ha_ws_t *ws, const uint8_t **payload, int *len) {
|
||||||
|
if (!ws->connected) return -1;
|
||||||
|
|
||||||
|
*payload = NULL;
|
||||||
|
*len = 0;
|
||||||
|
|
||||||
|
/* 读取帧头:2 字节 */
|
||||||
|
uint8_t hdr[2];
|
||||||
|
if (recv_all(ws, hdr, 2) != 0) {
|
||||||
|
ws->connected = 0;
|
||||||
|
return -1;
|
||||||
|
}
|
||||||
|
|
||||||
|
int opcode = hdr[0] & 0x0F;
|
||||||
|
int masked = (hdr[1] & 0x80) ? 1 : 0;
|
||||||
|
uint64_t frame_len = hdr[1] & 0x7F;
|
||||||
|
|
||||||
|
if (frame_len == 126) {
|
||||||
|
uint8_t ext[2];
|
||||||
|
if (recv_all(ws, ext, 2) != 0) { ws->connected = 0; return -1; }
|
||||||
|
frame_len = ((uint64_t)ext[0] << 8) | ext[1];
|
||||||
|
} else if (frame_len == 127) {
|
||||||
|
uint8_t ext[8];
|
||||||
|
if (recv_all(ws, ext, 8) != 0) { ws->connected = 0; return -1; }
|
||||||
|
frame_len = 0;
|
||||||
|
for (int i = 0; i < 8; i++) {
|
||||||
|
frame_len = (frame_len << 8) | ext[i];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/* 读取 mask key */
|
||||||
|
uint8_t mask_key[4] = {0, 0, 0, 0};
|
||||||
|
if (masked) {
|
||||||
|
if (recv_all(ws, mask_key, 4) != 0) { ws->connected = 0; return -1; }
|
||||||
|
}
|
||||||
|
|
||||||
|
/* 限制帧大小 */
|
||||||
|
if (frame_len > sizeof(ws->read_buf)) {
|
||||||
|
/* 帧太大,跳过 payload */
|
||||||
|
uint64_t skip = frame_len;
|
||||||
|
uint8_t tmp[256];
|
||||||
|
while (skip > 0) {
|
||||||
|
int to_skip = (skip > sizeof(tmp)) ? (int)sizeof(tmp) : (int)skip;
|
||||||
|
if (recv_all(ws, tmp, to_skip) != 0) { ws->connected = 0; return -1; }
|
||||||
|
skip -= to_skip;
|
||||||
|
}
|
||||||
|
return -1; /* 返回错误,帧太大 */
|
||||||
|
}
|
||||||
|
|
||||||
|
/* 读取 payload */
|
||||||
|
if (frame_len > 0) {
|
||||||
|
if (recv_all(ws, ws->read_buf, (int)frame_len) != 0) {
|
||||||
|
ws->connected = 0;
|
||||||
|
return -1;
|
||||||
|
}
|
||||||
|
/* 如果有 mask,解掩码 */
|
||||||
|
if (masked) {
|
||||||
|
for (uint64_t i = 0; i < frame_len; i++) {
|
||||||
|
ws->read_buf[i] ^= mask_key[i & 3];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
*payload = ws->read_buf;
|
||||||
|
*len = (int)frame_len;
|
||||||
|
|
||||||
|
switch (opcode) {
|
||||||
|
case WS_OPCODE_CLOSE:
|
||||||
|
ws->connected = 0;
|
||||||
|
return WS_OPCODE_CLOSE;
|
||||||
|
case WS_OPCODE_PING:
|
||||||
|
return WS_OPCODE_PING;
|
||||||
|
case WS_OPCODE_PONG:
|
||||||
|
return WS_OPCODE_PONG;
|
||||||
|
case WS_OPCODE_TEXT:
|
||||||
|
case WS_OPCODE_BINARY:
|
||||||
|
return opcode;
|
||||||
|
default:
|
||||||
|
return -1;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
void ha_ws_close(ha_ws_t *ws) {
|
||||||
|
if (ws->connected) {
|
||||||
|
ha_ws_send_frame(ws, WS_OPCODE_CLOSE, NULL, 0);
|
||||||
|
ws->connected = 0;
|
||||||
|
}
|
||||||
|
ws->transport->close(ws->transport->ctx);
|
||||||
|
}
|
||||||
62
remotedevice/src/ha_ws.h
Normal file
62
remotedevice/src/ha_ws.h
Normal file
@ -0,0 +1,62 @@
|
|||||||
|
#ifndef HA_WS_H
|
||||||
|
#define HA_WS_H
|
||||||
|
|
||||||
|
#include <stdint.h>
|
||||||
|
#include <stddef.h>
|
||||||
|
#include "../include/ha_remotedevice.h"
|
||||||
|
|
||||||
|
#ifdef __cplusplus
|
||||||
|
extern "C" {
|
||||||
|
#endif
|
||||||
|
|
||||||
|
/* ======================== WS 帧类型 ======================== */
|
||||||
|
#define WS_OPCODE_CONTINUATION 0x0
|
||||||
|
#define WS_OPCODE_TEXT 0x1
|
||||||
|
#define WS_OPCODE_BINARY 0x2
|
||||||
|
#define WS_OPCODE_CLOSE 0x8
|
||||||
|
#define WS_OPCODE_PING 0x9
|
||||||
|
#define WS_OPCODE_PONG 0xA
|
||||||
|
|
||||||
|
/* ======================== WS 连接 ======================== */
|
||||||
|
typedef struct {
|
||||||
|
ha_transport_t *transport; /* 用户实现的传输层 */
|
||||||
|
int connected; /* 是否已连接 */
|
||||||
|
uint8_t read_buf[8192]; /* 读缓冲区 */
|
||||||
|
int read_pos; /* 缓冲区中有效数据起始位置 */
|
||||||
|
int read_len; /* 缓冲区中有效数据长度 */
|
||||||
|
char host[256]; /* 缓存目标地址 */
|
||||||
|
uint16_t port;
|
||||||
|
char path[256];
|
||||||
|
char token[256];
|
||||||
|
} ha_ws_t;
|
||||||
|
|
||||||
|
/* 创建 WS 连接。返回 0 成功,非 0 失败。 */
|
||||||
|
int ha_ws_connect(ha_ws_t *ws, ha_transport_t *transport,
|
||||||
|
const char *host, uint16_t port,
|
||||||
|
const char *path, const char *token);
|
||||||
|
|
||||||
|
/* 发送文本帧。返回 0 成功。 */
|
||||||
|
int ha_ws_send_text(ha_ws_t *ws, const char *text);
|
||||||
|
|
||||||
|
/* 发送二进制帧。返回 0 成功。 */
|
||||||
|
int ha_ws_send_binary(ha_ws_t *ws, const uint8_t *data, int len);
|
||||||
|
|
||||||
|
/* 发送 ping。返回 0 成功。 */
|
||||||
|
int ha_ws_send_ping(ha_ws_t *ws);
|
||||||
|
|
||||||
|
/* 读取一帧。
|
||||||
|
* 返回 opcode (0x1/0x2/0x8/0x9/0xA),-1 表示关闭或错误。
|
||||||
|
* payload 和 len 指向内部缓冲区,在下次调用前有效。 */
|
||||||
|
int ha_ws_read_frame(ha_ws_t *ws, const uint8_t **payload, int *len);
|
||||||
|
|
||||||
|
/* 发送原始 WS 帧(内部使用,用于回复 ping) */
|
||||||
|
int ha_ws_send_frame(ha_ws_t *ws, int opcode, const uint8_t *payload, int len);
|
||||||
|
|
||||||
|
/* 关闭 WS 连接 */
|
||||||
|
void ha_ws_close(ha_ws_t *ws);
|
||||||
|
|
||||||
|
#ifdef __cplusplus
|
||||||
|
}
|
||||||
|
#endif
|
||||||
|
|
||||||
|
#endif /* HA_WS_H */
|
||||||
1509
remotedevice/test/test_ha_remotedevice.c
Normal file
1509
remotedevice/test/test_ha_remotedevice.c
Normal file
File diff suppressed because it is too large
Load Diff
@ -114,6 +114,9 @@ type IOInjector interface {
|
|||||||
// InjectInputSync 注入输入事件并同步等待 agent 回复,返回回复文本(无回复时返回空串)。
|
// InjectInputSync 注入输入事件并同步等待 agent 回复,返回回复文本(无回复时返回空串)。
|
||||||
// 用于通道消息的完整闭环:收到入站 → agent 处理 → 回复取回 → 送回通道。
|
// 用于通道消息的完整闭环:收到入站 → agent 处理 → 回复取回 → 送回通道。
|
||||||
InjectInputSync(source, channel, text string) string
|
InjectInputSync(source, channel, text string) string
|
||||||
|
// SetToolBlocks 插件工具注入多模态内容块(image_url/audio_url),内核在下一条
|
||||||
|
// tool message 的 content 数组里带上这些块,让模型在后续轮次看到图/听到音频。
|
||||||
|
SetToolBlocks(blocks []ContentBlock)
|
||||||
}
|
}
|
||||||
|
|
||||||
// EventType identifies the kind of system event.
|
// EventType identifies the kind of system event.
|
||||||
@ -127,6 +130,11 @@ const (
|
|||||||
EventReasoning EventType = "reasoning"
|
EventReasoning EventType = "reasoning"
|
||||||
EventStage EventType = "stage"
|
EventStage EventType = "stage"
|
||||||
EventSystem EventType = "system"
|
EventSystem EventType = "system"
|
||||||
|
|
||||||
|
// 流式增量事件(token 级):核心 process() 流式化后每收到一个增量块发布。
|
||||||
|
// 客户端可选订做真逐 token 渲染;聚合事件仍照常发布,旧订阅者不受影响。
|
||||||
|
EventReasoningDelta EventType = "reasoning_delta"
|
||||||
|
EventContentDelta EventType = "content_delta"
|
||||||
)
|
)
|
||||||
|
|
||||||
// Event represents a system event published by the kernel.
|
// Event represents a system event published by the kernel.
|
||||||
@ -147,6 +155,17 @@ type EventSubscriber interface {
|
|||||||
Subscribe(eventType EventType, handler EventHandler) func()
|
Subscribe(eventType EventType, handler EventHandler) func()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// PluginMgrAPI 提供插件管理能力(外部插件可调用)。
|
||||||
|
// 由 bridge 注入 dispatch 实现,走 C ABI CORE_PLUGIN_RELOAD_ONE 等。
|
||||||
|
type PluginMgrAPI interface {
|
||||||
|
// ReloadOne 重载单个插件(停止后重新加载)。
|
||||||
|
ReloadOne(name string) error
|
||||||
|
// ListLoadedPlugins 列出已加载插件。
|
||||||
|
ListLoadedPlugins() []string
|
||||||
|
// IsPluginDisabled 查询插件是否被禁用。
|
||||||
|
IsPluginDisabled(name string) bool
|
||||||
|
}
|
||||||
|
|
||||||
// StageScope controls which events a stage handler receives.
|
// StageScope controls which events a stage handler receives.
|
||||||
type StageScope int
|
type StageScope int
|
||||||
|
|
||||||
@ -200,6 +219,7 @@ type PluginSDK struct {
|
|||||||
sett SettingsAPI
|
sett SettingsAPI
|
||||||
social SocialAPI
|
social SocialAPI
|
||||||
events EventSubscriber
|
events EventSubscriber
|
||||||
|
plgMgr PluginMgrAPI
|
||||||
|
|
||||||
autoRestart bool
|
autoRestart bool
|
||||||
|
|
||||||
@ -347,6 +367,13 @@ func (s *PluginSDK) SetLLMAPI(llm LLMAPI) { s.llm = llm }
|
|||||||
func (s *PluginSDK) SetSocialAPI(social SocialAPI) { s.social = social }
|
func (s *PluginSDK) SetSocialAPI(social SocialAPI) { s.social = social }
|
||||||
func (s *PluginSDK) SetEventSubscriber(es EventSubscriber) { s.events = es }
|
func (s *PluginSDK) SetEventSubscriber(es EventSubscriber) { s.events = es }
|
||||||
|
|
||||||
|
// SetPluginMgrAPI sets the plugin manager API (called by the bridge at startup).
|
||||||
|
func (s *PluginSDK) SetPluginMgrAPI(pm PluginMgrAPI) { s.plgMgr = pm }
|
||||||
|
|
||||||
|
// PluginMgr returns the plugin manager API (ReloadOne / ReloadPlugins / list).
|
||||||
|
// May be nil if the host did not wire it.
|
||||||
|
func (s *PluginSDK) PluginMgr() PluginMgrAPI { return s.plgMgr }
|
||||||
|
|
||||||
// ---- IO Convenience Methods ----
|
// ---- IO Convenience Methods ----
|
||||||
|
|
||||||
// InjectInterruptText injects a text interrupt that can preempt current LLM processing.
|
// InjectInterruptText injects a text interrupt that can preempt current LLM processing.
|
||||||
@ -436,3 +463,22 @@ func (s *PluginSDK) RunOnRemoveHandlers() {
|
|||||||
handlers[i]()
|
handlers[i]()
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ContentBlock 是多模态内容块(OpenAI 格式:text/image_url/audio_url)。
|
||||||
|
// 插件工具返回结果时可用 PluginSDK.SetToolBlocks 注入,让下一轮 LLM
|
||||||
|
// 请求在 tool message 的 content 数组里带上图片/音频,实现"模型看图/听音频"。
|
||||||
|
type ContentBlock struct {
|
||||||
|
Type string `json:"type"`
|
||||||
|
Text string `json:"text,omitempty"`
|
||||||
|
ImageURL *ImageURL `json:"image_url,omitempty"`
|
||||||
|
AudioURL *AudioURL `json:"audio_url,omitempty"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type ImageURL struct {
|
||||||
|
URL string `json:"url"`
|
||||||
|
Detail string `json:"detail,omitempty"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type AudioURL struct {
|
||||||
|
URL string `json:"url"`
|
||||||
|
}
|
||||||
|
|||||||
@ -19,6 +19,11 @@ type SettingsAPI interface {
|
|||||||
// ListCore lists core config keys matching the prefix.
|
// ListCore lists core config keys matching the prefix.
|
||||||
ListCore(prefix string) ([]string, error)
|
ListCore(prefix string) ([]string, error)
|
||||||
|
|
||||||
|
// DataDir returns the plugin-specific data directory (guaranteed to exist):
|
||||||
|
// <daemon data>/plugin_data/<plugin_name>. Plugins should persist any
|
||||||
|
// runtime files (generated images, caches, downloads) here.
|
||||||
|
DataDir() string
|
||||||
|
|
||||||
// GetPlugin reads another plugin's config table.
|
// GetPlugin reads another plugin's config table.
|
||||||
GetPlugin(plugin, key string) (interface{}, error)
|
GetPlugin(plugin, key string) (interface{}, error)
|
||||||
|
|
||||||
|
|||||||
@ -69,18 +69,45 @@ type TemplateData struct {
|
|||||||
|
|
||||||
func cmdInit(args []string) {
|
func cmdInit(args []string) {
|
||||||
if len(args) < 1 {
|
if len(args) < 1 {
|
||||||
fmt.Println("Usage: plugindev init <name> [--lua]")
|
fmt.Println("Usage: plugindev init <name> [--lua] [--type remotedevice]")
|
||||||
os.Exit(1)
|
os.Exit(1)
|
||||||
}
|
}
|
||||||
|
|
||||||
name := args[0]
|
name := args[0]
|
||||||
isLua := false
|
isLua := false
|
||||||
|
isRemoteDevice := false
|
||||||
for _, a := range args[1:] {
|
for _, a := range args[1:] {
|
||||||
switch a {
|
switch a {
|
||||||
case "--lua":
|
case "--lua":
|
||||||
isLua = true
|
isLua = true
|
||||||
|
case "--type", "-t":
|
||||||
|
// handled in next iteration
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
// also check --type remotedevice as a single arg
|
||||||
|
for i, a := range args[1:] {
|
||||||
|
if a == "--type" || a == "-t" {
|
||||||
|
if i+1 < len(args[1:]) {
|
||||||
|
if args[1:][i+1] == "remotedevice" {
|
||||||
|
isRemoteDevice = true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if a == "--type=remotedevice" || a == "-t=remotedevice" {
|
||||||
|
isRemoteDevice = true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if isRemoteDevice && isLua {
|
||||||
|
fmt.Println("error: --type remotedevice and --lua are mutually exclusive")
|
||||||
|
os.Exit(1)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Remote device projects use different scaffold
|
||||||
|
if isRemoteDevice {
|
||||||
|
scaffoldRemoteDevice(name)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
dir := name
|
dir := name
|
||||||
if _, err := os.Stat(dir); !os.IsNotExist(err) {
|
if _, err := os.Stat(dir); !os.IsNotExist(err) {
|
||||||
@ -192,6 +219,57 @@ func detectSDKInfo() (modulePath, goVersion, sdkPath, sdkVersion string) {
|
|||||||
return modulePath, goVersion, root, sdkVersion
|
return modulePath, goVersion, root, sdkVersion
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// scaffoldRemoteDevice 创建远程设备适配器项目脚手架
|
||||||
|
func scaffoldRemoteDevice(name string) {
|
||||||
|
dir := name
|
||||||
|
if _, err := os.Stat(dir); !os.IsNotExist(err) {
|
||||||
|
fmt.Printf("error: directory %q already exists\n", dir)
|
||||||
|
os.Exit(1)
|
||||||
|
}
|
||||||
|
|
||||||
|
nameEn := strings.Title(strings.ReplaceAll(name, "-", " "))
|
||||||
|
|
||||||
|
data := TemplateData{
|
||||||
|
Plg: PlgConfig{
|
||||||
|
Name: name,
|
||||||
|
NameZh: "中文名",
|
||||||
|
NameEn: nameEn,
|
||||||
|
Version: "0.1.0",
|
||||||
|
Description: name + " remote device adapter",
|
||||||
|
Author: "HomeAgent",
|
||||||
|
Entry: name,
|
||||||
|
Tags: []string{name, "remotedevice"},
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
if err := os.MkdirAll(dir, 0755); err != nil {
|
||||||
|
fmt.Printf("error: create dir: %v\n", err)
|
||||||
|
os.Exit(1)
|
||||||
|
}
|
||||||
|
|
||||||
|
// 写入 main.c
|
||||||
|
writeTemplate(filepath.Join(dir, "main.c"), tmplRemoteDeviceMain, data)
|
||||||
|
|
||||||
|
// 写入 CMakeLists.txt
|
||||||
|
writeTemplate(filepath.Join(dir, "CMakeLists.txt"), tmplRemoteDeviceCMake, data)
|
||||||
|
|
||||||
|
// 创建 SDK 目录(symlink/copy)
|
||||||
|
sdkSrc := filepath.Join("..", "remotedevice")
|
||||||
|
sdkDst := filepath.Join(dir, "ha_remotedevice")
|
||||||
|
if _, err := os.Stat(sdkDst); os.IsNotExist(err) {
|
||||||
|
// 尝试创建符号链接,失败则提示
|
||||||
|
if err := os.Symlink(sdkSrc, sdkDst); err != nil {
|
||||||
|
fmt.Printf(" note: could not create symlink to SDK, copy manually:\n")
|
||||||
|
fmt.Printf(" cp -r %s %s\n", sdkSrc, sdkDst)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fmt.Printf("Created remote device adapter project %q\n", dir)
|
||||||
|
fmt.Printf(" cd %s && mkdir build && cd build && cmake .. && make\n", dir)
|
||||||
|
fmt.Printf(" Or include as subdirectory in your project:\n")
|
||||||
|
fmt.Printf(" add_subdirectory(%s)\n", dir)
|
||||||
|
}
|
||||||
|
|
||||||
func writeTemplate(path, content string, data TemplateData) {
|
func writeTemplate(path, content string, data TemplateData) {
|
||||||
tmpl, err := template.New("").Parse(content)
|
tmpl, err := template.New("").Parse(content)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
|
|||||||
@ -30,15 +30,20 @@ func help() {
|
|||||||
fmt.Print(`HomeAgent Plugin Dev Tool
|
fmt.Print(`HomeAgent Plugin Dev Tool
|
||||||
|
|
||||||
Usage:
|
Usage:
|
||||||
plugindev init <name> Scaffold a new plugin project
|
plugindev init <name> Scaffold a new plugin project
|
||||||
plugindev build [flags] Compile and package plugin
|
plugindev init <name> --lua Create Lua plugin
|
||||||
plugindev clean Clean build/dist artifacts
|
plugindev init <name> --type remotedevice
|
||||||
plugindev debug [dir] Interpret and debug plugin source
|
Create C remote device adapter
|
||||||
plugindev sdk <command> Manage SDK versions
|
plugindev build [flags] Compile and package plugin
|
||||||
|
plugindev clean Clean build/dist artifacts
|
||||||
|
plugindev debug [dir] Interpret and debug plugin source
|
||||||
|
plugindev sdk <command> Manage SDK versions
|
||||||
|
|
||||||
Flags:
|
Flags:
|
||||||
--outdir Output directory (default: dist)
|
--outdir Output directory (default: dist)
|
||||||
--target Target OS/arch (e.g. linux/amd64), repeatable
|
--target Target OS/arch (e.g. linux/amd64), repeatable
|
||||||
--lua Create Lua plugin (for init)
|
--lua Create Lua plugin (for init)
|
||||||
|
--type Project type: "remotedevice" (for init)
|
||||||
|
-t Alias for --type
|
||||||
`)
|
`)
|
||||||
}
|
}
|
||||||
|
|||||||
@ -405,6 +405,10 @@ enum {
|
|||||||
CORE_SETTINGS_DUMP = 44,
|
CORE_SETTINGS_DUMP = 44,
|
||||||
CORE_SETTINGS_PLUGINS = 45,
|
CORE_SETTINGS_PLUGINS = 45,
|
||||||
CORE_REGISTER_INPUT_CH = 46,
|
CORE_REGISTER_INPUT_CH = 46,
|
||||||
|
CORE_INJECT_INPUT_SYNC = 47,
|
||||||
|
CORE_PLUGIN_RELOAD_ONE = 48,
|
||||||
|
CORE_PLUGIN_LIST_LOADED = 49,
|
||||||
|
CORE_PLUGIN_IS_DISABLED = 50,
|
||||||
};
|
};
|
||||||
|
|
||||||
#ifdef __cplusplus
|
#ifdef __cplusplus
|
||||||
@ -519,6 +523,7 @@ func buildPluginSDK(name string) *sdk.PluginSDK {
|
|||||||
base.SetLLMAPI(dispatchLLM{})
|
base.SetLLMAPI(dispatchLLM{})
|
||||||
base.SetSocialAPI(dispatchSocial{})
|
base.SetSocialAPI(dispatchSocial{})
|
||||||
base.SetTextMemoryAPI(dispatchTextMemory{})
|
base.SetTextMemoryAPI(dispatchTextMemory{})
|
||||||
|
base.SetPluginMgrAPI(dispatchPluginMgr{})
|
||||||
base.SetInputChannelRegistrar(
|
base.SetInputChannelRegistrar(
|
||||||
func(name string, def sdk.ChannelDef) error {
|
func(name string, def sdk.ChannelDef) error {
|
||||||
defJSON, _ := json.Marshal(def)
|
defJSON, _ := json.Marshal(def)
|
||||||
@ -577,6 +582,31 @@ func (dispatchSocial) ListPersons() ([]string, error) { r, e := callString(40, "
|
|||||||
type dispatchTextMemory struct{}
|
type dispatchTextMemory struct{}
|
||||||
func (dispatchTextMemory) Append(evt sdk.TextEvent) error { b, _ := json.Marshal(evt); return callVoid(41, string(b), "", "", 0, 0) }
|
func (dispatchTextMemory) Append(evt sdk.TextEvent) error { b, _ := json.Marshal(evt); return callVoid(41, string(b), "", "", 0, 0) }
|
||||||
|
|
||||||
|
// ---- dispatchPluginMgr (CORE_PLUGIN_RELOAD_ONE = 48) ----
|
||||||
|
|
||||||
|
type dispatchPluginMgr struct{}
|
||||||
|
|
||||||
|
func (dispatchPluginMgr) ReloadOne(name string) error {
|
||||||
|
return callVoid(48, name, "", "", 0, 0)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (dispatchPluginMgr) ListLoadedPlugins() []string {
|
||||||
|
r, e := callString(49, "", "", "", 0, 0)
|
||||||
|
if e != nil || r == "" {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
var list []string
|
||||||
|
if json.Unmarshal([]byte(r), &list) != nil {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
return list
|
||||||
|
}
|
||||||
|
|
||||||
|
func (dispatchPluginMgr) IsPluginDisabled(name string) bool {
|
||||||
|
r, e := callString(50, name, "", "", 0, 0)
|
||||||
|
return e == nil && r == "1"
|
||||||
|
}
|
||||||
|
|
||||||
// ---- dispatchSettings (inline) ----
|
// ---- dispatchSettings (inline) ----
|
||||||
|
|
||||||
type dispatchSettings struct{}
|
type dispatchSettings struct{}
|
||||||
@ -617,6 +647,9 @@ func (d *dispatchSettings) Dump() map[string]interface{} {
|
|||||||
func (d *dispatchSettings) Plugins() []string {
|
func (d *dispatchSettings) Plugins() []string {
|
||||||
r, e := callString(45, "", "", "", 0, 0); if e != nil || r == "" { return nil }; var v []string; json.Unmarshal([]byte(r), &v); return v
|
r, e := callString(45, "", "", "", 0, 0); if e != nil || r == "" { return nil }; var v []string; json.Unmarshal([]byte(r), &v); return v
|
||||||
}
|
}
|
||||||
|
func (d *dispatchSettings) DataDir() string {
|
||||||
|
r, e := callString(51, "", "", "", 0, 0); if e != nil { return "" }; return r
|
||||||
|
}
|
||||||
|
|
||||||
// ---- Go callbacks (called from z_entry.c via C) ----
|
// ---- Go callbacks (called from z_entry.c via C) ----
|
||||||
|
|
||||||
@ -831,6 +864,353 @@ PluginAPI* plugin_init(void) {
|
|||||||
}
|
}
|
||||||
`
|
`
|
||||||
|
|
||||||
|
// ============================================================
|
||||||
|
// Remote Device Adapter Templates
|
||||||
|
// ============================================================
|
||||||
|
|
||||||
|
const tmplRemoteDeviceMain = `#include <stdio.h>
|
||||||
|
#include <string.h>
|
||||||
|
#include <stdlib.h>
|
||||||
|
|
||||||
|
#include "ha_remotedevice.h"
|
||||||
|
|
||||||
|
/* ============================================================
|
||||||
|
* {{.Plg.Name}} — Remote Device Adapter
|
||||||
|
*
|
||||||
|
* 声明式远程设备接入示例。
|
||||||
|
* 用户只需实现:
|
||||||
|
* 1. ha_transport_t 的 4 个函数
|
||||||
|
* 2. 声明 handlers 表(设备支持哪些命令 + 对应的处理函数)
|
||||||
|
* 其余协议细节(WS 握手、hello/bind、心跳、重连、命令分发、结果回执)由 SDK 自动处理。
|
||||||
|
* ============================================================ */
|
||||||
|
|
||||||
|
/* ====================== 传输层实现 ======================
|
||||||
|
*
|
||||||
|
* 请为你的平台实现以下 4 个函数:
|
||||||
|
* connect(ctx, host, port) — 建立 TCP 连接
|
||||||
|
* send(ctx, data, len) — 发送数据
|
||||||
|
* recv(ctx, buf, len) — 接收数据(阻塞,返回实际接收字节数)
|
||||||
|
* close(ctx) — 关闭连接
|
||||||
|
*
|
||||||
|
* 示例:POSIX socket 实现
|
||||||
|
*/
|
||||||
|
|
||||||
|
#if defined(_WIN32) || defined(_WIN64)
|
||||||
|
/* Windows 平台需包含 winsock2.h */
|
||||||
|
#error "Please implement transport for your platform (see example below)"
|
||||||
|
#else
|
||||||
|
/* POSIX (Linux, macOS, ESP-IDF, Zephyr, etc.) */
|
||||||
|
#include <sys/socket.h>
|
||||||
|
#include <netinet/in.h>
|
||||||
|
#include <arpa/inet.h>
|
||||||
|
#include <netdb.h>
|
||||||
|
#include <unistd.h>
|
||||||
|
|
||||||
|
struct transport_ctx {
|
||||||
|
int sock;
|
||||||
|
};
|
||||||
|
|
||||||
|
static int transport_connect(void *ctx, const char *host, uint16_t port) {
|
||||||
|
struct transport_ctx *tc = (struct transport_ctx *)ctx;
|
||||||
|
struct hostent *he = gethostbyname(host);
|
||||||
|
if (!he) return -1;
|
||||||
|
tc->sock = socket(AF_INET, SOCK_STREAM, 0);
|
||||||
|
if (tc->sock < 0) return -1;
|
||||||
|
struct sockaddr_in addr;
|
||||||
|
memset(&addr, 0, sizeof(addr));
|
||||||
|
addr.sin_family = AF_INET;
|
||||||
|
addr.sin_port = htons(port);
|
||||||
|
memcpy(&addr.sin_addr, he->h_addr_list[0], he->h_length);
|
||||||
|
if (connect(tc->sock, (struct sockaddr *)&addr, sizeof(addr)) < 0) {
|
||||||
|
close(tc->sock);
|
||||||
|
tc->sock = -1;
|
||||||
|
return -1;
|
||||||
|
}
|
||||||
|
return 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
static int transport_send(void *ctx, const uint8_t *data, int len) {
|
||||||
|
struct transport_ctx *tc = (struct transport_ctx *)ctx;
|
||||||
|
int sent = 0;
|
||||||
|
while (sent < len) {
|
||||||
|
int n = (int)send(tc->sock, data + sent, len - sent, 0);
|
||||||
|
if (n <= 0) return -1;
|
||||||
|
sent += n;
|
||||||
|
}
|
||||||
|
return sent;
|
||||||
|
}
|
||||||
|
|
||||||
|
static int transport_recv(void *ctx, uint8_t *buf, int len) {
|
||||||
|
struct transport_ctx *tc = (struct transport_ctx *)ctx;
|
||||||
|
int n = (int)recv(tc->sock, buf, len, 0);
|
||||||
|
return n;
|
||||||
|
}
|
||||||
|
|
||||||
|
static void transport_close(void *ctx) {
|
||||||
|
struct transport_ctx *tc = (struct transport_ctx *)ctx;
|
||||||
|
if (tc->sock >= 0) {
|
||||||
|
close(tc->sock);
|
||||||
|
tc->sock = -1;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
#endif
|
||||||
|
|
||||||
|
/* ====================== 声明式命令处理 ======================
|
||||||
|
*
|
||||||
|
* 每个命令对应一个处理函数,通过填写 ha_cmd_result_t 返回数据。
|
||||||
|
* SDK 自动回执结果,无需手动调用 send_result。
|
||||||
|
*
|
||||||
|
* 返回方式:
|
||||||
|
* 1. 文本输出:填写 result->output
|
||||||
|
* 2. 二进制数据:设置 result->has_binary=1 并填写 binary_data/len/mime
|
||||||
|
* 3. 错误:设置 result->status=1 并填写 result->error
|
||||||
|
* 4. 返回 HA_OK 表示处理成功,其他值表示处理失败
|
||||||
|
*/
|
||||||
|
|
||||||
|
/* ESP32-CAM 摄像头处理 */
|
||||||
|
static ha_status_t handle_camerasue(const char *req_id, const char *args,
|
||||||
|
ha_cmd_result_t *result, void *userdata) {
|
||||||
|
(void)req_id; (void)userdata;
|
||||||
|
int duration = 0;
|
||||||
|
if (args && args[0]) duration = atoi(args);
|
||||||
|
printf("[camera] %s (duration=%ds)\n", duration ? "record" : "snapshot", duration);
|
||||||
|
|
||||||
|
/* 返回文本结果(base64 图片) */
|
||||||
|
result->status = 0;
|
||||||
|
result->output = "data:image/jpeg;base64,/9j/4AAQ...";
|
||||||
|
return HA_OK;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* 屏幕截图处理 */
|
||||||
|
static ha_status_t handle_screensee(const char *req_id, const char *args,
|
||||||
|
ha_cmd_result_t *result, void *userdata) {
|
||||||
|
(void)req_id; (void)args; (void)userdata;
|
||||||
|
printf("[screen] screenshot\n");
|
||||||
|
result->status = 0;
|
||||||
|
result->output = "data:image/png;base64,iVBORw0KGgo...";
|
||||||
|
return HA_OK;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* 语音播报处理 */
|
||||||
|
static ha_status_t handle_speakeruse(const char *req_id, const char *args,
|
||||||
|
ha_cmd_result_t *result, void *userdata) {
|
||||||
|
(void)req_id; (void)userdata;
|
||||||
|
printf("[speaker] TTS: %s\n", args ? args : "");
|
||||||
|
result->status = 0;
|
||||||
|
result->output = "speakeruse done";
|
||||||
|
return HA_OK;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* 远程操控处理(computeruse) */
|
||||||
|
static ha_status_t handle_computeruse(const char *req_id, const char *args,
|
||||||
|
ha_cmd_result_t *result, void *userdata) {
|
||||||
|
(void)req_id; (void)userdata;
|
||||||
|
const char *action = NULL;
|
||||||
|
const char *json_str = NULL;
|
||||||
|
ha_cmd_parse_json(args, &action, &json_str);
|
||||||
|
printf("[computeruse] action=%s\n", action ? action : "unknown");
|
||||||
|
result->status = 0;
|
||||||
|
result->output = "computeruse done";
|
||||||
|
return HA_OK;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* 剪贴板读取 */
|
||||||
|
static ha_status_t handle_clipboardsee(const char *req_id, const char *args,
|
||||||
|
ha_cmd_result_t *result, void *userdata) {
|
||||||
|
(void)req_id; (void)args; (void)userdata;
|
||||||
|
result->status = 0;
|
||||||
|
result->output = "clipboard content";
|
||||||
|
return HA_OK;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* 剪贴板写入 */
|
||||||
|
static ha_status_t handle_clipboardsue(const char *req_id, const char *args,
|
||||||
|
ha_cmd_result_t *result, void *userdata) {
|
||||||
|
(void)req_id; (void)userdata;
|
||||||
|
printf("[clipboard] write: %s\n", args ? args : "");
|
||||||
|
result->status = 0;
|
||||||
|
result->output = "clipboard written";
|
||||||
|
return HA_OK;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* 屏幕显示 */
|
||||||
|
static ha_status_t handle_screensue(const char *req_id, const char *args,
|
||||||
|
ha_cmd_result_t *result, void *userdata) {
|
||||||
|
(void)req_id; (void)userdata;
|
||||||
|
printf("[screensue] show: %s\n", args ? args : "");
|
||||||
|
result->status = 0;
|
||||||
|
result->output = "screensue shown";
|
||||||
|
return HA_OK;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Shell 命令处理 */
|
||||||
|
static ha_status_t handle_shell(const char *req_id, const char *args,
|
||||||
|
ha_cmd_result_t *result, void *userdata) {
|
||||||
|
(void)req_id; (void)userdata;
|
||||||
|
printf("[shell] cmd: %s\n", args ? args : "");
|
||||||
|
result->status = 0;
|
||||||
|
result->output = "shell output";
|
||||||
|
return HA_OK;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* 设备信息查询 */
|
||||||
|
static ha_status_t handle_deviceinfo(const char *req_id, const char *args,
|
||||||
|
ha_cmd_result_t *result, void *userdata) {
|
||||||
|
(void)req_id; (void)args; (void)userdata;
|
||||||
|
result->status = 0;
|
||||||
|
result->output = "{\"platform\":\"linux\",\"arch\":\"x86_64\"}";
|
||||||
|
return HA_OK;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ====================== 连接状态回调 ====================== */
|
||||||
|
|
||||||
|
static void on_state(int connected, void *userdata) {
|
||||||
|
(void)userdata;
|
||||||
|
printf("[devicelink] state: %s\n", connected ? "connected" : "disconnected");
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ====================== 主函数 ====================== */
|
||||||
|
|
||||||
|
int main(int argc, char *argv[]) {
|
||||||
|
/* 传输层上下文 */
|
||||||
|
struct transport_ctx tctx;
|
||||||
|
tctx.sock = -1;
|
||||||
|
|
||||||
|
ha_transport_t transport = {
|
||||||
|
.connect = transport_connect,
|
||||||
|
.send = transport_send,
|
||||||
|
.recv = transport_recv,
|
||||||
|
.close = transport_close,
|
||||||
|
.ctx = &tctx,
|
||||||
|
};
|
||||||
|
|
||||||
|
/* ===== 声明式设备配置 ===== */
|
||||||
|
|
||||||
|
/* 声明设备能力 */
|
||||||
|
const char *caps[] = {
|
||||||
|
"status", "cmdrun", "deviceinfo",
|
||||||
|
"camerasue", "screensee", "speakeruse",
|
||||||
|
"computeruse", "clipboardsee", "clipboardsue",
|
||||||
|
"screensue",
|
||||||
|
NULL
|
||||||
|
};
|
||||||
|
|
||||||
|
/* 声明命令处理表:设备支持哪些命令,以及对应的处理函数 */
|
||||||
|
ha_cmd_handler_def_t handlers[] = {
|
||||||
|
{.command = "shell", .handler = handle_shell},
|
||||||
|
{.command = "camerasue", .handler = handle_camerasue},
|
||||||
|
{.command = "screensee", .handler = handle_screensee},
|
||||||
|
{.command = "speakeruse", .handler = handle_speakeruse},
|
||||||
|
{.command = "computeruse", .handler = handle_computeruse},
|
||||||
|
{.command = "clipboardsee", .handler = handle_clipboardsee},
|
||||||
|
{.command = "clipboardsue", .handler = handle_clipboardsue},
|
||||||
|
{.command = "screensue", .handler = handle_screensue},
|
||||||
|
{.command = "deviceinfo", .handler = handle_deviceinfo},
|
||||||
|
{.command = NULL}, /* 标记结束 */
|
||||||
|
};
|
||||||
|
|
||||||
|
ha_config_t config = {
|
||||||
|
.transport = transport,
|
||||||
|
.server = "127.0.0.1:9890",
|
||||||
|
.token = "your-token-here",
|
||||||
|
.device = {
|
||||||
|
.device_id = "{{.Plg.Name}}",
|
||||||
|
.name = "{{.Plg.NameEn}}",
|
||||||
|
.kind = "computer",
|
||||||
|
.caps = caps,
|
||||||
|
.info_json = "{\"platform\":\"linux\",\"arch\":\"x86_64\"}",
|
||||||
|
},
|
||||||
|
.handlers = handlers, /* 声明式命令处理表 */
|
||||||
|
.on_state = on_state,
|
||||||
|
.ping_interval = 30,
|
||||||
|
};
|
||||||
|
|
||||||
|
ha_client_t *client = ha_client_new(&config);
|
||||||
|
if (!client) {
|
||||||
|
fprintf(stderr, "Failed to create client\n");
|
||||||
|
return 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
printf("Starting remote device adapter: {{.Plg.Name}}\n");
|
||||||
|
printf(" Server: %s\n", config.server);
|
||||||
|
printf(" Device ID: %s\n", config.device.device_id);
|
||||||
|
printf(" Kind: %s\n", config.device.kind);
|
||||||
|
printf(" Caps: ");
|
||||||
|
for (const char **p = caps; *p; p++) printf("%s ", *p);
|
||||||
|
printf("\n");
|
||||||
|
|
||||||
|
ha_status_t st = ha_client_start(client);
|
||||||
|
if (st != HA_OK) {
|
||||||
|
fprintf(stderr, "Failed to connect: %d\n", st);
|
||||||
|
ha_client_destroy(client);
|
||||||
|
return 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
printf("Connected! Entering main loop...\n");
|
||||||
|
|
||||||
|
/* 主循环 */
|
||||||
|
while (1) {
|
||||||
|
ha_status_t st = ha_client_process(client);
|
||||||
|
if (st == HA_ERR_DISCONNECTED) {
|
||||||
|
printf("Disconnected, exiting.\n");
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
#if defined(_WIN32) || defined(_WIN64)
|
||||||
|
Sleep(10);
|
||||||
|
#else
|
||||||
|
usleep(10000);
|
||||||
|
#endif
|
||||||
|
}
|
||||||
|
|
||||||
|
ha_client_stop(client);
|
||||||
|
ha_client_destroy(client);
|
||||||
|
return 0;
|
||||||
|
}
|
||||||
|
`
|
||||||
|
|
||||||
|
const tmplRemoteDeviceCMake = `cmake_minimum_required(VERSION 3.10)
|
||||||
|
project({{.Plg.Name}} VERSION 0.1.0 LANGUAGES C)
|
||||||
|
|
||||||
|
# ============================================================
|
||||||
|
# {{.Plg.Name}} — Remote Device Adapter
|
||||||
|
# ============================================================
|
||||||
|
|
||||||
|
# 设置 SDK 路径(默认使用内置 SDK,也可通过 -DSDK_PATH=... 指定)
|
||||||
|
set(SDK_PATH "${CMAKE_CURRENT_SOURCE_DIR}/ha_remotedevice"
|
||||||
|
CACHE PATH "Path to ha_remotedevice SDK")
|
||||||
|
|
||||||
|
# 添加 SDK 子目录
|
||||||
|
if(EXISTS "${SDK_PATH}/CMakeLists.txt")
|
||||||
|
add_subdirectory(${SDK_PATH} ha_remotedevice)
|
||||||
|
else()
|
||||||
|
message(FATAL_ERROR "ha_remotedevice SDK not found at ${SDK_PATH}")
|
||||||
|
endif()
|
||||||
|
|
||||||
|
# 创建设备适配器可执行文件
|
||||||
|
add_executable(${PROJECT_NAME}
|
||||||
|
main.c
|
||||||
|
)
|
||||||
|
|
||||||
|
# 链接 SDK
|
||||||
|
target_link_libraries(${PROJECT_NAME} PRIVATE ha_remotedevice)
|
||||||
|
|
||||||
|
# 包含 SDK 头文件
|
||||||
|
target_include_directories(${PROJECT_NAME} PRIVATE
|
||||||
|
${HA_REMOTEDEVICE_INCLUDE_DIR}
|
||||||
|
)
|
||||||
|
|
||||||
|
# 编译选项
|
||||||
|
if(CMAKE_C_COMPILER_ID MATCHES "GNU|Clang")
|
||||||
|
target_compile_options(${PROJECT_NAME} PRIVATE
|
||||||
|
-Wall -Wextra -Wpedantic
|
||||||
|
-Wno-unused-parameter
|
||||||
|
)
|
||||||
|
endif()
|
||||||
|
|
||||||
|
# 安装
|
||||||
|
install(TARGETS ${PROJECT_NAME} RUNTIME DESTINATION bin)
|
||||||
|
`
|
||||||
|
|
||||||
const tmplReadme = `# {{.Plg.Name}}
|
const tmplReadme = `# {{.Plg.Name}}
|
||||||
|
|
||||||
{{.Plg.Description}}
|
{{.Plg.Description}}
|
||||||
|
|||||||
Reference in New Issue
Block a user