diff --git a/.gitignore b/.gitignore index 2463098..6b2c52c 100644 --- a/.gitignore +++ b/.gitignore @@ -1,6 +1,8 @@ # Build artifacts *.so *.dll +*.o +*.exe *.hmap plugin.json diff --git a/README.md b/README.md index 7b0cfcd..4f821d0 100644 --- a/README.md +++ b/README.md @@ -308,6 +308,324 @@ enabled := sdk.AutoRestart() | [rss](example/rss) | Go | RSS 订阅 | | [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://: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://:8080/api/v1/device/list +# 预期输出包含: {"device_id":"esp32-cam-1","status":"online",...} + +# 向设备发送命令(测试 camerasue) +curl -X POST http://: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` + ## 构建与安装 ### 构建 diff --git a/README_EN.md b/README_EN.md index 5531ab4..ba5acc5 100644 --- a/README_EN.md +++ b/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 | | [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://: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://: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://: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 ### Build diff --git a/meta/meta.go b/meta/meta.go index dfa7d67..6b69bb8 100644 --- a/meta/meta.go +++ b/meta/meta.go @@ -99,4 +99,9 @@ const ( CoreSettingsDefs = 43 CoreSettingsDump = 44 CoreSettingsPlugins = 45 + CoreRegisterInputCh = 46 + CoreInjectInputSync = 47 + CorePluginReloadOne = 48 + CorePluginListLoaded = 49 + CorePluginIsDisabled = 50 ) diff --git a/remotedevice/CMakeLists.txt b/remotedevice/CMakeLists.txt new file mode 100644 index 0000000..c7fc578 --- /dev/null +++ b/remotedevice/CMakeLists.txt @@ -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: $") +message(STATUS " Shared lib: ${BUILD_SHARED_LIBS}") +message(STATUS " No alloc: ${HA_NO_ALLOC}") \ No newline at end of file diff --git a/remotedevice/include/ha_remotedevice.h b/remotedevice/include/ha_remotedevice.h new file mode 100644 index 0000000..84e64a7 --- /dev/null +++ b/remotedevice/include/ha_remotedevice.h @@ -0,0 +1,216 @@ +#ifndef HA_REMOTEDEVICE_H +#define HA_REMOTEDEVICE_H + +#include +#include + +#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 */ \ No newline at end of file diff --git a/remotedevice/src/ha_json.c b/remotedevice/src/ha_json.c new file mode 100644 index 0000000..0d3af91 --- /dev/null +++ b/remotedevice/src/ha_json.c @@ -0,0 +1,369 @@ +#include "ha_json.h" +#include +#include +#include +#include + +/* ======================== 解析器 ======================== */ + +/* 前向声明 */ +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; +} \ No newline at end of file diff --git a/remotedevice/src/ha_json.h b/remotedevice/src/ha_json.h new file mode 100644 index 0000000..1560d9e --- /dev/null +++ b/remotedevice/src/ha_json.h @@ -0,0 +1,107 @@ +#ifndef HA_JSON_H +#define HA_JSON_H + +#include +#include + +#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 */ \ No newline at end of file diff --git a/remotedevice/src/ha_remotedevice.c b/remotedevice/src/ha_remotedevice.c new file mode 100644 index 0000000..4ab127d --- /dev/null +++ b/remotedevice/src/ha_remotedevice.c @@ -0,0 +1,628 @@ +#include "ha_remotedevice.h" +#include "ha_json.h" +#include "ha_ws.h" +#include +#include +#include + +#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; +} + diff --git a/remotedevice/src/ha_ws.c b/remotedevice/src/ha_ws.c new file mode 100644 index 0000000..266509d --- /dev/null +++ b/remotedevice/src/ha_ws.c @@ -0,0 +1,325 @@ +#include "ha_ws.h" +#include +#include +#include + +/* 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); +} \ No newline at end of file diff --git a/remotedevice/src/ha_ws.h b/remotedevice/src/ha_ws.h new file mode 100644 index 0000000..e47cfcd --- /dev/null +++ b/remotedevice/src/ha_ws.h @@ -0,0 +1,62 @@ +#ifndef HA_WS_H +#define HA_WS_H + +#include +#include +#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 */ \ No newline at end of file diff --git a/remotedevice/test/test_ha_remotedevice.c b/remotedevice/test/test_ha_remotedevice.c new file mode 100644 index 0000000..affd3f9 --- /dev/null +++ b/remotedevice/test/test_ha_remotedevice.c @@ -0,0 +1,1509 @@ +/** + * ha_remotedevice 全面测试 + * + * 覆盖:JSON 解析/构建、WS 帧编解码、工具函数、 + * 客户端完整生命周期、命令分发、二进制分块、语音数据、事件上报。 + * + * 编译 (POSIX/Linux/macOS): + * gcc -I../include -I../src ../src/*.c test_ha_remotedevice.c -lpthread -o test + * + * 编译 (Windows/MinGW): + * gcc -I../include -I../src ../src/*.c test_ha_remotedevice.c -lpthread -lws2_32 -o test + */ + +#include "ha_remotedevice.h" +#include "ha_json.h" +#include "ha_ws.h" + +#include +#include +#include +#include +#include + +/* 前向声明:ha_client_send_data_chunked 供测试调用 */ +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 defined(_WIN32) || defined(_WIN64) + #define _CRT_SECURE_NO_WARNINGS + #include + #include + #include + typedef int socklen_t; + #define sleep(t) Sleep((t)*1000) + #define usleep(t) Sleep((t)/1000) + typedef unsigned thread_func_ret; + #define THREAD_RETURN return 0 + #define SIGPIPE 13 + static void signal(int sig, void (*func)(int)) { (void)sig; (void)func; } + static void sock_close(int fd) { closesocket(fd); } +#else + #include + #include + #include + #include + #include + #include + #include + typedef void *thread_func_ret; + #define THREAD_RETURN return NULL + static void sock_close(int fd) { close(fd); } +#endif + +/* ================================================================== + * 简易测试框架 + * ================================================================== */ +static int tests_passed = 0; +static int tests_failed = 0; +static int tests_skipped = 0; + +#define TEST_BEGIN(name) do { \ + printf(" TEST: %s ... ", name); \ + fflush(stdout); \ + do { (void)0 + +#define TEST_END() } while(0); \ + printf("PASS\n"); \ + tests_passed++; \ +} while(0) + +#define TEST_FAIL(msg) do { \ + printf("FAIL: %s\n", msg); \ + tests_failed++; \ + return; \ +} while(0) + +#define TEST_ASSERT(cond, msg) do { \ + if (!(cond)) { TEST_FAIL(msg); } \ +} while(0) + +#define TEST_SKIP(reason) do { \ + printf("SKIP: %s\n", reason); \ + tests_skipped++; \ + return; \ +} while(0) + +/* ================================================================== + * 工具:在本地端口启动一个 TCP 服务器(用于 mock 网关) + * ================================================================== */ +typedef struct { + int listen_fd; + int client_fd; + int port; +#if defined(_WIN32) || defined(_WIN64) + uintptr_t thread; +#else + pthread_t thread; +#endif + volatile int running; +} mock_server_t; + +/* 发送 WS 文本帧(服务器端,无需掩码) */ +static void mock_send_text(int fd, const char *json) { + int len = (int)strlen(json); + uint8_t hdr[10]; + int hdr_len = 2; + hdr[0] = 0x80 | 0x1; + if (len < 126) { + hdr[1] = (uint8_t)len; + } else if (len < 65536) { + hdr[1] = 126; + hdr[2] = (uint8_t)(len >> 8); + hdr[3] = (uint8_t)(len & 0xFF); + hdr_len = 4; + } else { + hdr[1] = 127; + uint64_t l = (uint64_t)len; + for (int i = 8; i > 0; i--) { + hdr[1 + i] = (uint8_t)(l & 0xFF); + l >>= 8; + } + hdr_len = 10; + } + send(fd, (const char *)hdr, hdr_len, 0); + send(fd, json, len, 0); +} + +/* 发送 WS 二进制帧(服务器端,无需掩码) */ +static void mock_send_binary(int fd, const uint8_t *data, int len) { + uint8_t hdr[10]; + int hdr_len = 2; + hdr[0] = 0x80 | 0x2; + if (len < 126) { + hdr[1] = (uint8_t)len; + } else if (len < 65536) { + hdr[1] = 126; + hdr[2] = (uint8_t)(len >> 8); + hdr[3] = (uint8_t)(len & 0xFF); + hdr_len = 4; + } else { + hdr[1] = 127; + uint64_t l = (uint64_t)len; + for (int i = 8; i > 0; i--) { + hdr[1 + i] = (uint8_t)(l & 0xFF); + l >>= 8; + } + hdr_len = 10; + } + send(fd, (const char *)hdr, hdr_len, 0); + send(fd, (const char *)data, len, 0); +} + +/* 读取 WS 文本帧(服务器端,解析掩码) */ +static char *mock_read_text(int fd) { + uint8_t hdr[2]; + if (recv(fd, (char *)hdr, 2, 0) != 2) return NULL; + int masked = (hdr[1] & 0x80) ? 1 : 0; + uint64_t len = hdr[1] & 0x7F; + if (len == 126) { + uint8_t ext[2]; + if (recv(fd, (char *)ext, 2, 0) != 2) return NULL; + len = ((uint64_t)ext[0] << 8) | ext[1]; + } else if (len == 127) { + uint8_t ext[8]; + if (recv(fd, (char *)ext, 8, 0) != 8) return NULL; + len = 0; + for (int i = 0; i < 8; i++) len = (len << 8) | ext[i]; + } + uint8_t mask_key[4] = {0}; + if (masked) { + if (recv(fd, (char *)mask_key, 4, 0) != 4) return NULL; + } + char *buf = (char *)malloc((size_t)len + 1); + if (!buf) return NULL; + if (len > 0) { + if ((int)recv(fd, buf, (int)len, 0) != (int)len) { + free(buf); + return NULL; + } + if (masked) { + for (uint64_t i = 0; i < len; i++) + buf[i] ^= mask_key[i & 3]; + } + } + buf[len] = '\0'; + return buf; +} + +/* mock 服务器线程 */ +#if defined(_WIN32) || defined(_WIN64) +static unsigned __stdcall mock_server_thread(void *arg) { +#else +static void *mock_server_thread(void *arg) { +#endif + mock_server_t *ms = (mock_server_t *)arg; + struct sockaddr_in client_addr; + socklen_t addr_len = sizeof(client_addr); + int fd = accept(ms->listen_fd, (struct sockaddr *)&client_addr, &addr_len); + if (fd < 0) THREAD_RETURN; + ms->client_fd = fd; + + /* 读取 WS 升级请求 */ + char buf[4096] = {0}; + int n = 0; + while (n < (int)sizeof(buf) - 1) { + int r = (int)recv(fd, buf + n, 1, 0); + if (r <= 0) break; + n += r; + buf[n] = '\0'; + if (n >= 4 && strcmp(buf + n - 4, "\r\n\r\n") == 0) break; + } + + /* 发送 101 响应 */ + const char *resp = + "HTTP/1.1 101 Switching Protocols\r\n" + "Upgrade: websocket\r\n" + "Connection: Upgrade\r\n" + "Sec-WebSocket-Accept: s3pPLMBiTxaQ9kYGzzhZRbK+xOo=\r\n" + "\r\n"; + send(fd, resp, (int)strlen(resp), 0); + + /* 协议循环 */ + char *msg; + while (ms->running) { + msg = mock_read_text(fd); + if (!msg) break; + + if (strstr(msg, "\"hello\"")) { + mock_send_text(fd, "{\"op\":\"hello_ack\",\"status\":\"ok\"}"); + } else if (strstr(msg, "\"bind\"")) { + mock_send_text(fd, "{\"op\":\"bind_ack\",\"status\":\"ok\",\"device_id\":\"test-dev\"}"); + } + /* cmd_result, event, status, cmd_data_start/end 可以忽略 */ + free(msg); + } + sock_close(fd); + ms->client_fd = -1; + THREAD_RETURN; +} + +static mock_server_t *mock_server_start(int port) { + mock_server_t *ms = (mock_server_t *)calloc(1, sizeof(mock_server_t)); + +#if defined(_WIN32) || defined(_WIN64) + WSADATA wsa; + WSAStartup(MAKEWORD(2, 2), &wsa); +#endif + + ms->listen_fd = (int)socket(AF_INET, SOCK_STREAM, 0); + int opt = 1; + setsockopt(ms->listen_fd, SOL_SOCKET, SO_REUSEADDR, + (const char *)&opt, sizeof(opt)); + + struct sockaddr_in addr; + memset(&addr, 0, sizeof(addr)); + addr.sin_family = AF_INET; + addr.sin_addr.s_addr = INADDR_ANY; + addr.sin_port = htons(port); + if (bind(ms->listen_fd, (struct sockaddr *)&addr, sizeof(addr)) < 0) { + free(ms); + return NULL; + } + listen(ms->listen_fd, 1); + ms->port = port; + ms->running = 1; + ms->client_fd = -1; + +#if defined(_WIN32) || defined(_WIN64) + ms->thread = _beginthreadex(NULL, 0, mock_server_thread, ms, 0, NULL); +#else + pthread_create(&ms->thread, NULL, mock_server_thread, ms); +#endif + usleep(200000); /* 等待服务器就绪 */ + return ms; +} + +static void mock_server_stop(mock_server_t *ms) { + if (!ms) return; + ms->running = 0; + if (ms->client_fd >= 0) sock_close(ms->client_fd); + sock_close(ms->listen_fd); +#if defined(_WIN32) || defined(_WIN64) + WaitForSingleObject((HANDLE)ms->thread, 3000); + WSACleanup(); +#else + pthread_join(ms->thread, NULL); +#endif + free(ms); +} + +/* ================================================================== + * 传输层实现(POSIX socket) + * ================================================================== */ +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 = (int)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) { + sock_close(tc->sock); + tc->sock = -1; + return -1; + } + /* 设置 100ms 接收超时,使 ha_client_process 不会永久阻塞 */ +#if defined(_WIN32) || defined(_WIN64) + DWORD timeout = 100; + setsockopt(tc->sock, SOL_SOCKET, SO_RCVTIMEO, (const char *)&timeout, sizeof(timeout)); +#else + struct timeval tv = {0, 100000}; + setsockopt(tc->sock, SOL_SOCKET, SO_RCVTIMEO, &tv, sizeof(tv)); +#endif + 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, (const char *)(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; + return (int)recv(tc->sock, (char *)buf, len, 0); +} + +static void transport_close(void *ctx) { + struct transport_ctx *tc = (struct transport_ctx *)ctx; + if (tc->sock >= 0) { + sock_close(tc->sock); + tc->sock = -1; + } +} + +/* ================================================================== + * mock 服务器:发送命令 + * ================================================================== */ +static void mock_send_cmd(mock_server_t *ms, const char *cmd_type, + const char *req_id, const char *cmd) { + char buf[4096]; + char escaped[2048]; + int ei = 0; + /* 转义 cmd 中的双引号和反斜杠 */ + if (cmd) { + for (int i = 0; cmd[i] && ei < (int)sizeof(escaped) - 6; i++) { + if (cmd[i] == '"' || cmd[i] == '\\') { + escaped[ei++] = '\\'; + if (ei >= (int)sizeof(escaped) - 1) break; + } + escaped[ei++] = cmd[i]; + } + } + escaped[ei] = '\0'; + int n = snprintf(buf, sizeof(buf), + "{\"op\":\"cmd\",\"req_id\":\"%s\",\"cmd_type\":\"%s\",\"command\":\"%s\"}", + req_id ? req_id : "", cmd_type ? cmd_type : "", escaped); + mock_send_text(ms->client_fd, buf); +} + +/* ================================================================== + * 测试用例 + * ================================================================== */ + +/* ---------- 1. JSON 解析器 ---------- */ +static void test_json_parser(void) { + TEST_BEGIN("JSON parser: parse object"); + + const char *json = "{\"op\":\"hello\",\"device\":{\"id\":\"test\",\"caps\":[\"a\",\"b\"]}}"; + ha_json_node_t *root = ha_json_parse(json); + TEST_ASSERT(root != NULL, "parse failed"); + TEST_ASSERT(root->type == HA_JSON_OBJECT, "not an object"); + + const char *op = ha_json_get_string(root, "op"); + TEST_ASSERT(op != NULL && strcmp(op, "hello") == 0, "op mismatch"); + + ha_json_node_t *dev = ha_json_get(root, "device"); + TEST_ASSERT(dev != NULL && dev->type == HA_JSON_OBJECT, "device not object"); + + const char *id = ha_json_get_string(dev, "id"); + TEST_ASSERT(id != NULL && strcmp(id, "test") == 0, "device id mismatch"); + + ha_json_node_t *caps = ha_json_get(dev, "caps"); + TEST_ASSERT(caps != NULL && caps->type == HA_JSON_ARRAY, "caps not array"); + TEST_ASSERT(ha_json_array_len(caps) == 2, "caps length wrong"); + + ha_json_node_t *c0 = ha_json_array_get(caps, 0); + TEST_ASSERT(c0 != NULL && c0->type == HA_JSON_STRING && + strcmp(c0->str_val, "a") == 0, "caps[0] mismatch"); + + ha_json_free(root); + TEST_END(); +} + +static void test_json_int(void) { + TEST_BEGIN("JSON parser: int fields"); + + const char *json = "{\"count\":42,\"total\":100500,\"neg\":-7}"; + ha_json_node_t *root = ha_json_parse(json); + TEST_ASSERT(root != NULL, "parse failed"); + + TEST_ASSERT(ha_json_get_int(root, "count", -1) == 42, "count mismatch"); + TEST_ASSERT(ha_json_get_int(root, "total", -1) == 100500, "total mismatch"); + TEST_ASSERT(ha_json_get_int(root, "neg", 0) == -7, "neg mismatch"); + TEST_ASSERT(ha_json_get_int(root, "nonexistent", -999) == -999, "default wrong"); + + ha_json_free(root); + TEST_END(); +} + +static void test_json_builder(void) { + TEST_BEGIN("JSON builder: object with string + int + bool"); + + char buf[256]; + ha_json_builder_t jb; + ha_json_builder_init(&jb, buf, sizeof(buf)); + ha_json_builder_begin_object(&jb); + ha_json_builder_string(&jb, "op", "hello"); + ha_json_builder_int(&jb, "seq", 1); + ha_json_builder_bool(&jb, "active", 1); + ha_json_builder_end_object(&jb); + + const char *r = ha_json_builder_str(&jb); + TEST_ASSERT(r != NULL, "builder returned NULL"); + TEST_ASSERT(strstr(r, "\"op\":\"hello\"") != NULL, "missing op"); + TEST_ASSERT(strstr(r, "\"seq\":1") != NULL, "missing seq"); + TEST_ASSERT(strstr(r, "\"active\":true") != NULL, "missing bool"); + + TEST_END(); +} + +static void test_json_array_builder(void) { + TEST_BEGIN("JSON builder: array of strings"); + + char buf[256]; + ha_json_builder_t jb; + ha_json_builder_init(&jb, buf, sizeof(buf)); + ha_json_builder_begin_object(&jb); + ha_json_builder_key(&jb, "caps"); + ha_json_builder_begin_array(&jb); + ha_json_builder_add_string(&jb, "camera"); + ha_json_builder_add_string(&jb, "screen"); + ha_json_builder_end_array(&jb); + ha_json_builder_end_object(&jb); + + const char *r = ha_json_builder_str(&jb); + TEST_ASSERT(r != NULL, "builder returned NULL"); + TEST_ASSERT(strstr(r, "\"camera\"") != NULL, "missing camera"); + TEST_ASSERT(strstr(r, "\"screen\"") != NULL, "missing screen"); + + TEST_END(); +} + +/* ---------- 2. 工具函数 ---------- */ +static void test_parse_homeagent(void) { + TEST_BEGIN("ha_cmd_parse_homeagent: basic"); + + const char *cap, *args; + + ha_cmd_parse_homeagent("camerasue 5", &cap, &args); + TEST_ASSERT(strcmp(cap, "camerasue") == 0, "cap mismatch"); + TEST_ASSERT(strcmp(args, "5") == 0, "args mismatch"); + + ha_cmd_parse_homeagent("screensee", &cap, &args); + TEST_ASSERT(strcmp(cap, "screensee") == 0, "cap mismatch (no args)"); + TEST_ASSERT(strcmp(args, "") == 0, "args should be empty"); + + ha_cmd_parse_homeagent("homeagent-camerasue 3", &cap, &args); + TEST_ASSERT(strcmp(cap, "camerasue") == 0, "prefix not stripped"); + TEST_ASSERT(strcmp(args, "3") == 0, "args after prefix"); + + ha_cmd_parse_homeagent("", &cap, &args); + TEST_ASSERT(strcmp(cap, "") == 0, "empty input"); + + TEST_END(); +} + +static void test_parse_json(void) { + TEST_BEGIN("ha_cmd_parse_json: action + json"); + + const char *action, *json_str; + + ha_cmd_parse_json("computeruse {\"action\":\"click\",\"x\":100}", + &action, &json_str); + TEST_ASSERT(strcmp(action, "computeruse") == 0, "action mismatch"); + TEST_ASSERT(strstr(json_str, "\"action\"") != NULL, "json missing"); + + ha_cmd_parse_json("screensee", &action, &json_str); + TEST_ASSERT(strcmp(action, "screensee") == 0, "action no json"); + TEST_ASSERT(strcmp(json_str, "") == 0, "json should be empty"); + + TEST_END(); +} + +static void test_base64(void) { + TEST_BEGIN("ha_base64_encode: basic"); + + const uint8_t data[] = "Hello, World!"; + char out[64]; + int n = ha_base64_encode(data, 13, out, sizeof(out)); + TEST_ASSERT(n > 0, "encode returned 0"); + TEST_ASSERT(strcmp(out, "SGVsbG8sIFdvcmxkIQ==") == 0, "base64 mismatch"); + + const uint8_t jpeg[] = {0xFF, 0xD8, 0xFF}; + n = ha_base64_encode(jpeg, 3, out, sizeof(out)); + TEST_ASSERT(n > 0, "short encode failed"); + TEST_ASSERT(strcmp(out, "/9j/") == 0, "jpeg magic mismatch"); + + /* 缓冲区不足 */ + n = ha_base64_encode(data, 13, out, 5); + TEST_ASSERT(n > 5, "should return needed size"); + + TEST_END(); +} + +/* ---------- 3. WS 帧编解码 ---------- */ +static void test_ws_frame(void) { +#if defined(_WIN32) || defined(_WIN64) + printf(" TEST: WS frame: encode/decode with mask ... SKIP: socketpair not available on Windows\n"); + tests_skipped++; + return; +#else + TEST_BEGIN("WS frame: encode/decode with mask"); + + /* 使用 TCP 本地连接测试 */ + int sv[2]; + TEST_ASSERT(socketpair(AF_UNIX, SOCK_STREAM, 0, sv) == 0, + "socketpair failed"); + + const char *test_payload = "hello world"; + int test_len = (int)strlen(test_payload); + + /* 发送端:手动构造 WS 帧 */ + uint8_t frame[1024]; + int off = 0; + frame[off++] = 0x80 | 0x1; + frame[off++] = 0x80 | (uint8_t)test_len; + uint8_t mask[4] = {0x01, 0x02, 0x03, 0x04}; + memcpy(frame + off, mask, 4); off += 4; + for (int i = 0; i < test_len; i++) + frame[off++] = test_payload[i] ^ mask[i & 3]; + send(sv[0], (const char *)frame, off, 0); + + /* 接收端 */ + ha_transport_t transport = { + .send = transport_send, .recv = transport_recv, + .close = transport_close, + }; + struct transport_ctx tctx_reader = { .sock = sv[1] }; + transport.ctx = &tctx_reader; + + ha_ws_t ws; + memset(&ws, 0, sizeof(ws)); + ws.transport = &transport; + ws.connected = 1; + + const uint8_t *payload; + int len; + int opcode = ha_ws_read_frame(&ws, &payload, &len); + TEST_ASSERT(opcode == 0x1, "expected text frame"); + TEST_ASSERT(len == test_len, "length mismatch"); + TEST_ASSERT(memcmp(payload, test_payload, len) == 0, "payload mismatch"); + + sock_close(sv[0]); + sock_close(sv[1]); + TEST_END(); +#endif +} + +/* ---------- 4. ha_version ---------- */ +static void test_version(void) { + TEST_BEGIN("ha_version: returns non-empty"); + + const char *v = ha_version(); + TEST_ASSERT(v != NULL && v[0] != '\0', "version empty"); + printf("(v=%s) ", v); + TEST_END(); +} + +/* ---------- 5. 完整客户端生命周期 ---------- */ +static volatile int lifecycle_state = 0; + +static void test_lifecycle_on_state(int connected, void *userdata) { + (void)userdata; + lifecycle_state = connected ? 1 : 0; +} + +static ha_status_t test_handler_camerasue(const char *req_id, const char *args, + ha_cmd_result_t *result, void *userdata) { + (void)req_id; (void)userdata; + printf(" [handler] camerasue called, args=%s\n", args ? args : ""); + result->status = 0; + result->output = "data:image/jpeg;base64,test123"; + return HA_OK; +} + +static ha_status_t test_handler_shell(const char *req_id, const char *args, + ha_cmd_result_t *result, void *userdata) { + (void)req_id; (void)userdata; + printf(" [handler] shell called: %s\n", args ? args : ""); + result->status = 0; + result->output = "shell output here"; + return HA_OK; +} + +static void test_client_lifecycle(void) { + TEST_BEGIN("Client lifecycle: connect -> hello -> bind -> ready"); + + mock_server_t *ms = mock_server_start(19890); + TEST_ASSERT(ms != NULL, "mock server start failed"); + + 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[] = {"camera", "status", NULL}; + ha_cmd_handler_def_t handlers[] = { + {.command = "shell", .handler = test_handler_shell}, + {.command = "camerasue", .handler = test_handler_camerasue}, + {.command = NULL}, + }; + + ha_config_t config = { + .transport = transport, + .server = "127.0.0.1:19890", + .token = "test-token", + .device = { + .device_id = "test-dev", + .name = "Test Device", + .kind = "camera", + .caps = caps, + }, + .handlers = handlers, + .on_state = test_lifecycle_on_state, + .ping_interval = 30, + }; + + lifecycle_state = 0; + ha_client_t *client = ha_client_new(&config); + TEST_ASSERT(client != NULL, "client new failed"); + + ha_status_t st = ha_client_start(client); + TEST_ASSERT(st == HA_OK, "client start failed"); + usleep(200000); + TEST_ASSERT(lifecycle_state == 1, "should be connected"); + + /* 发送命令验证分发 */ + mock_send_cmd(ms, "homeagent", "req-1", "camerasue 3"); + usleep(200000); + ha_client_process(client); + + mock_send_cmd(ms, "shell", "req-2", "ls -la"); + usleep(200000); + ha_client_process(client); + + /* 未注册的命令 */ + mock_send_cmd(ms, "homeagent", "req-3", "unknowncmd"); + usleep(100000); + ha_client_process(client); + + ha_client_stop(client); + ha_client_destroy(client); + mock_server_stop(ms); + + TEST_END(); +} + +/* ---------- 6. 命令分发 ---------- */ +static volatile int cmd_camerasue_fired = 0; +static volatile int cmd_shell_fired = 0; +static char cmd_req_id[128] = ""; + +static ha_status_t test_handler_camerasue2(const char *req_id, const char *args, + ha_cmd_result_t *result, void *userdata) { + (void)userdata; + cmd_camerasue_fired = 1; + strncpy(cmd_req_id, req_id, sizeof(cmd_req_id) - 1); + if (strcmp(args, "5") != 0) { + printf(" [handler] args mismatch: expected '5', got '%s'\n", args); + result->status = 1; + result->error = "args mismatch"; + return HA_OK; + } + result->status = 0; + result->output = "snapshot taken"; + return HA_OK; +} + +static ha_status_t test_handler_shell2(const char *req_id, const char *args, + ha_cmd_result_t *result, void *userdata) { + (void)userdata; + cmd_shell_fired = 1; + if (strstr(args, "ls") == NULL) { + printf(" [handler] args should contain 'ls', got '%s'\n", args); + result->status = 1; + result->error = "args mismatch"; + return HA_OK; + } + result->status = 0; + result->output = "file1.txt\nfile2.txt"; + return HA_OK; +} + +static void test_command_dispatch(void) { + TEST_BEGIN("Command dispatch: homeagent + shell"); + + mock_server_t *ms = mock_server_start(19891); + TEST_ASSERT(ms != NULL, "mock server start failed"); + + 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[] = {"camera", "cmd", NULL}; + ha_cmd_handler_def_t handlers[] = { + {.command = "shell", .handler = test_handler_shell2}, + {.command = "camerasue", .handler = test_handler_camerasue2}, + {.command = NULL}, + }; + + ha_config_t config = { + .transport = transport, + .server = "127.0.0.1:19891", + .token = "test-token", + .device = { + .device_id = "cmd-dev", + .name = "Cmd Test", + .kind = "camera", + .caps = caps, + }, + .handlers = handlers, + .ping_interval = 30, + }; + + cmd_camerasue_fired = 0; + cmd_shell_fired = 0; + memset(cmd_req_id, 0, sizeof(cmd_req_id)); + + ha_client_t *client = ha_client_new(&config); + ha_status_t st = ha_client_start(client); + TEST_ASSERT(st == HA_OK, "client start failed"); + usleep(200000); + + /* 发送 homeagent 命令 */ + mock_send_cmd(ms, "homeagent", "req-cam", "camerasue 5"); + usleep(200000); + ha_client_process(client); + TEST_ASSERT(cmd_camerasue_fired == 1, "camerasue handler not called"); + TEST_ASSERT(strcmp(cmd_req_id, "req-cam") == 0, "req_id mismatch"); + + /* 发送 shell 命令 */ + mock_send_cmd(ms, "shell", "req-sh", "ls -la /tmp"); + usleep(200000); + ha_client_process(client); + TEST_ASSERT(cmd_shell_fired == 1, "shell handler not called"); + + ha_client_stop(client); + ha_client_destroy(client); + mock_server_stop(ms); + + TEST_END(); +} + +/* ---------- 7. 事件上报 ---------- */ +static void test_event_report(void) { + TEST_BEGIN("Event report: client sends event to server"); + + mock_server_t *ms = mock_server_start(19892); + TEST_ASSERT(ms != NULL, "mock server start failed"); + + 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, + }; + + ha_config_t config = { + .transport = transport, + .server = "127.0.0.1:19892", + .token = "test-token", + .device = { + .device_id = "evt-dev", + .name = "Event Test", + .kind = "camera", + .caps = (const char *[]){"camera", NULL}, + }, + .handlers = NULL, + .ping_interval = 30, + }; + + ha_client_t *client = ha_client_new(&config); + ha_status_t st = ha_client_start(client); + TEST_ASSERT(st == HA_OK, "client start failed"); + usleep(200000); + + ha_client_send_event(client, "motion_detected", "{\"zone\":\"front_door\"}"); + usleep(100000); + ha_client_process(client); + + ha_client_stop(client); + ha_client_destroy(client); + mock_server_stop(ms); + + TEST_END(); +} + +/* ---------- 8. 状态上报 ---------- */ +static void test_status_report(void) { + TEST_BEGIN("Status report: client sends status to server"); + + mock_server_t *ms = mock_server_start(19893); + TEST_ASSERT(ms != NULL, "mock server start failed"); + + 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, + }; + + ha_config_t config = { + .transport = transport, + .server = "127.0.0.1:19893", + .token = "test-token", + .device = { + .device_id = "st-dev", + .name = "Status Test", + .kind = "camera", + .caps = (const char *[]){"camera", NULL}, + }, + .handlers = NULL, + .ping_interval = 30, + }; + + ha_client_t *client = ha_client_new(&config); + ha_status_t st = ha_client_start(client); + TEST_ASSERT(st == HA_OK, "client start failed"); + usleep(200000); + + ha_client_send_status(client, "offline"); + usleep(100000); + ha_client_process(client); + + ha_client_stop(client); + ha_client_destroy(client); + mock_server_stop(ms); + + TEST_END(); +} + +/* ---------- 9. 二进制分块回传 ---------- */ +static void test_binary_chunked(void) { + TEST_BEGIN("Binary chunked transfer: send video data to server"); + + mock_server_t *ms = mock_server_start(19894); + TEST_ASSERT(ms != NULL, "mock server start failed"); + + 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, + }; + + ha_cmd_handler_def_t handlers[] = { + {.command = "camerasue", .handler = test_handler_camerasue}, + {.command = NULL}, + }; + + ha_config_t config = { + .transport = transport, + .server = "127.0.0.1:19894", + .token = "test-token", + .device = { + .device_id = "bin-dev", + .name = "Binary Test", + .kind = "camera", + .caps = (const char *[]){"camera", NULL}, + }, + .handlers = handlers, + .ping_interval = 30, + }; + + ha_client_t *client = ha_client_new(&config); + ha_status_t st = ha_client_start(client); + TEST_ASSERT(st == HA_OK, "client start failed"); + usleep(200000); + + /* 模拟录像数据 */ + uint8_t video_data[20000]; + for (int i = 0; i < 20000; i++) video_data[i] = (uint8_t)(i % 251); + + ha_client_send_data_chunked(client, "req-video", "camera_video", + "video/mp4", video_data, 20000); + usleep(500000); + ha_client_process(client); + + ha_client_stop(client); + ha_client_destroy(client); + mock_server_stop(ms); + + TEST_END(); +} + +/* ---------- 10. 未注册命令处理 ---------- */ +static void test_unsupported_command(void) { + TEST_BEGIN("Unsupported command: returns error gracefully"); + + mock_server_t *ms = mock_server_start(19895); + TEST_ASSERT(ms != NULL, "mock server start failed"); + + 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, + }; + + ha_cmd_handler_def_t handlers[] = { + {.command = "camerasue", .handler = test_handler_camerasue}, + {.command = NULL}, + }; + + ha_config_t config = { + .transport = transport, + .server = "127.0.0.1:19895", + .token = "test-token", + .device = { + .device_id = "unsup-dev", + .name = "Unsupported Test", + .kind = "camera", + .caps = (const char *[]){"camera", NULL}, + }, + .handlers = handlers, + .ping_interval = 30, + }; + + ha_client_t *client = ha_client_new(&config); + ha_status_t st = ha_client_start(client); + TEST_ASSERT(st == HA_OK, "client start failed"); + usleep(200000); + + /* 发送未注册命令 */ + mock_send_cmd(ms, "homeagent", "req-unsup", "screensee"); + usleep(100000); + ha_client_process(client); + + /* 发送已注册命令 */ + mock_send_cmd(ms, "homeagent", "req-ok", "camerasue"); + usleep(100000); + ha_client_process(client); + + ha_client_stop(client); + ha_client_destroy(client); + mock_server_stop(ms); + + TEST_END(); +} + +/* ---------- 11. 语音数据接收 ---------- */ +static volatile int speech_received = 0; +static uint8_t speech_data[4096]; +static int speech_len = 0; + +static void test_on_binary(const char *req_id, const char *kind, + const char *mime, const uint8_t *data, + int len, void *userdata) { + (void)req_id; (void)kind; (void)mime; (void)userdata; + speech_received = 1; + speech_len = len < (int)sizeof(speech_data) ? len : (int)sizeof(speech_data); + memcpy(speech_data, data, (size_t)speech_len); +} + +static void test_speech_receive(void) { + TEST_BEGIN("Speech data receive: cmd_speech_start -> binary -> end"); + + mock_server_t *ms = mock_server_start(19896); + TEST_ASSERT(ms != NULL, "mock server start failed"); + + 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, + }; + + ha_config_t config = { + .transport = transport, + .server = "127.0.0.1:19896", + .token = "test-token", + .device = { + .device_id = "speech-dev", + .name = "Speech Test", + .kind = "speaker", + .caps = (const char *[]){"speaker", NULL}, + }, + .handlers = NULL, + .on_binary = test_on_binary, + .ping_interval = 30, + }; + + speech_received = 0; + speech_len = 0; + + ha_client_t *client = ha_client_new(&config); + ha_status_t st = ha_client_start(client); + TEST_ASSERT(st == HA_OK, "client start failed"); + usleep(200000); + + /* mock 服务器发送语音数据 */ + const char *audio_data = "RIFF....fake-wav-data...."; + mock_send_text(ms->client_fd, + "{\"op\":\"cmd_speech_start\",\"req_id\":\"req-speech\"," + "\"kind\":\"speech\",\"mime\":\"audio/wav\",\"total\":25}"); + usleep(50000); + mock_send_binary(ms->client_fd, (const uint8_t *)audio_data, 25); + usleep(50000); + mock_send_text(ms->client_fd, + "{\"op\":\"cmd_speech_end\",\"req_id\":\"req-speech\"}"); + usleep(200000); + + /* 处理帧 */ + for (int i = 0; i < 50; i++) { + ha_client_process(client); + if (speech_received) break; + usleep(20000); + } + + TEST_ASSERT(speech_received == 1, "speech handler not called"); + TEST_ASSERT(speech_len == 25, "speech length mismatch"); + TEST_ASSERT(memcmp(speech_data, audio_data, 25) == 0, "speech data mismatch"); + + ha_client_stop(client); + ha_client_destroy(client); + mock_server_stop(ms); + + TEST_END(); +} + +/* ---------- 12. 二进制分块 via handler ---------- */ +static ha_status_t test_handler_video(const char *req_id, const char *args, + ha_cmd_result_t *result, void *userdata) { + (void)req_id; (void)userdata; + printf(" [handler] video record %s\n", args ? args : ""); + + static uint8_t video[5000]; + for (int i = 0; i < 5000; i++) video[i] = (uint8_t)(i & 0xFF); + + result->status = 0; + result->has_binary = 1; + result->binary_data = video; + result->binary_len = 5000; + result->binary_mime = "video/mp4"; + return HA_OK; +} + +static void test_binary_via_handler(void) { + TEST_BEGIN("Binary via handler: handler sets has_binary, SDK auto-chunks"); + + mock_server_t *ms = mock_server_start(19897); + TEST_ASSERT(ms != NULL, "mock server start failed"); + + 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, + }; + + ha_cmd_handler_def_t handlers[] = { + {.command = "camerasue", .handler = test_handler_video}, + {.command = NULL}, + }; + + ha_config_t config = { + .transport = transport, + .server = "127.0.0.1:19897", + .token = "test-token", + .device = { + .device_id = "bin2-dev", + .name = "Binary Via Handler", + .kind = "camera", + .caps = (const char *[]){"camera", NULL}, + }, + .handlers = handlers, + .ping_interval = 30, + }; + + ha_client_t *client = ha_client_new(&config); + ha_status_t st = ha_client_start(client); + TEST_ASSERT(st == HA_OK, "client start failed"); + usleep(200000); + + mock_send_cmd(ms, "homeagent", "req-video2", "camerasue 10"); + usleep(500000); + ha_client_process(client); + usleep(100000); + ha_client_process(client); + + ha_client_stop(client); + ha_client_destroy(client); + mock_server_stop(ms); + + TEST_END(); +} + +/* ---------- 13. computeruse:结构化 JSON 命令分发 ---------- */ +static volatile int computeruse_fired = 0; +static char computeruse_action[64] = ""; +static int computeruse_x = 0; +static int computeruse_y = 0; + +static ha_status_t test_handler_computeruse(const char *req_id, const char *args, + ha_cmd_result_t *result, void *userdata) { + (void)req_id; (void)userdata; + computeruse_fired = 1; + /* 解析 JSON 参数 */ + if (args && args[0] == '{') { + ha_json_node_t *root = ha_json_parse(args); + if (root) { + const char *act = ha_json_get_string(root, "action"); + if (act) strncpy(computeruse_action, act, sizeof(computeruse_action) - 1); + computeruse_x = ha_json_get_int(root, "x", 0); + computeruse_y = ha_json_get_int(root, "y", 0); + ha_json_free(root); + } + } + result->status = 0; + result->output = "clicked at (100,200)"; + return HA_OK; +} + +static void test_computeruse_dispatch(void) { + TEST_BEGIN("Computeruse: structured JSON command dispatch"); + + mock_server_t *ms = mock_server_start(19898); + TEST_ASSERT(ms != NULL, "mock server start failed"); + + 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[] = {"computeruse", "cmd", NULL}; + ha_cmd_handler_def_t handlers[] = { + {.command = "computeruse", .handler = test_handler_computeruse}, + {.command = NULL}, + }; + + ha_config_t config = { + .transport = transport, + .server = "127.0.0.1:19898", + .token = "test-token", + .device = { + .device_id = "cu-dev", + .name = "ComputerUse Test", + .kind = "computer", + .caps = caps, + }, + .handlers = handlers, + .ping_interval = 30, + }; + + computeruse_fired = 0; + memset(computeruse_action, 0, sizeof(computeruse_action)); + computeruse_x = 0; + computeruse_y = 0; + + ha_client_t *client = ha_client_new(&config); + ha_status_t st = ha_client_start(client); + TEST_ASSERT(st == HA_OK, "client start failed"); + usleep(200000); + + /* 发送 computeruse 命令(带结构化 JSON 参数) */ + mock_send_cmd(ms, "homeagent", "req-cu", + "computeruse {\"action\":\"click\",\"x\":100,\"y\":200}"); + usleep(200000); + ha_client_process(client); + TEST_ASSERT(computeruse_fired == 1, "computeruse handler not called"); + TEST_ASSERT(strcmp(computeruse_action, "click") == 0, + "action should be 'click'"); + TEST_ASSERT(computeruse_x == 100, "x should be 100"); + TEST_ASSERT(computeruse_y == 200, "y should be 200"); + + ha_client_stop(client); + ha_client_destroy(client); + mock_server_stop(ms); + + TEST_END(); +} + +/* ---------- 14. clipboardsee/clipboardsue 命令分发 ---------- */ +static volatile int clipboardsee_fired = 0; +static volatile int clipboardsue_fired = 0; +static char clipboardsue_text[256] = ""; + +static ha_status_t test_handler_clipboardsee(const char *req_id, const char *args, + ha_cmd_result_t *result, void *userdata) { + (void)req_id; (void)args; (void)userdata; + clipboardsee_fired = 1; + result->status = 0; + result->output = "clipboard content here"; + return HA_OK; +} + +static ha_status_t test_handler_clipboardsue(const char *req_id, const char *args, + ha_cmd_result_t *result, void *userdata) { + (void)req_id; (void)userdata; + clipboardsue_fired = 1; + if (args) strncpy(clipboardsue_text, args, sizeof(clipboardsue_text) - 1); + result->status = 0; + result->output = "clipboard set"; + return HA_OK; +} + +static void test_clipboard_dispatch(void) { + TEST_BEGIN("Clipboard: clipboardsee + clipboardsue dispatch"); + + mock_server_t *ms = mock_server_start(19899); + TEST_ASSERT(ms != NULL, "mock server start failed"); + + 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[] = {"clipboard", "cmd", NULL}; + ha_cmd_handler_def_t handlers[] = { + {.command = "clipboardsee", .handler = test_handler_clipboardsee}, + {.command = "clipboardsue", .handler = test_handler_clipboardsue}, + {.command = NULL}, + }; + + ha_config_t config = { + .transport = transport, + .server = "127.0.0.1:19899", + .token = "test-token", + .device = { + .device_id = "clip-dev", + .name = "Clipboard Test", + .kind = "computer", + .caps = caps, + }, + .handlers = handlers, + .ping_interval = 30, + }; + + clipboardsee_fired = 0; + clipboardsue_fired = 0; + memset(clipboardsue_text, 0, sizeof(clipboardsue_text)); + + ha_client_t *client = ha_client_new(&config); + ha_status_t st = ha_client_start(client); + TEST_ASSERT(st == HA_OK, "client start failed"); + usleep(200000); + + /* 发送 clipboardsee 命令 */ + mock_send_cmd(ms, "homeagent", "req-cs", "clipboardsee"); + usleep(200000); + ha_client_process(client); + TEST_ASSERT(clipboardsee_fired == 1, "clipboardsee handler not called"); + + /* 发送 clipboardsue 命令 */ + mock_send_cmd(ms, "homeagent", "req-cw", "clipboardsue Hello World"); + usleep(200000); + ha_client_process(client); + TEST_ASSERT(clipboardsue_fired == 1, "clipboardsue handler not called"); + TEST_ASSERT(strcmp(clipboardsue_text, "Hello World") == 0, + "clipboardsue text mismatch"); + + ha_client_stop(client); + ha_client_destroy(client); + mock_server_stop(ms); + + TEST_END(); +} + +/* ---------- 15. screensee:带 data URL 的结果回传 ---------- */ +static ha_status_t test_handler_screensee(const char *req_id, const char *args, + ha_cmd_result_t *result, void *userdata) { + (void)req_id; (void)args; (void)userdata; + result->status = 0; + /* 返回 data URL 格式的 base64 图像数据 */ + result->output = "data:image/jpeg;base64,/9j/4AAQSkZJRg=="; + return HA_OK; +} + +static void test_screensee_data_url(void) { + TEST_BEGIN("Screensee: handler returns data URL result"); + + mock_server_t *ms = mock_server_start(19900); + TEST_ASSERT(ms != NULL, "mock server start failed"); + + 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[] = {"screen", "cmd", NULL}; + ha_cmd_handler_def_t handlers[] = { + {.command = "screensee", .handler = test_handler_screensee}, + {.command = NULL}, + }; + + ha_config_t config = { + .transport = transport, + .server = "127.0.0.1:19900", + .token = "test-token", + .device = { + .device_id = "see-dev", + .name = "Screensee Test", + .kind = "computer", + .caps = caps, + }, + .handlers = handlers, + .ping_interval = 30, + }; + + ha_client_t *client = ha_client_new(&config); + ha_status_t st = ha_client_start(client); + TEST_ASSERT(st == HA_OK, "client start failed"); + usleep(200000); + + /* 发送 screensee 命令 */ + mock_send_cmd(ms, "homeagent", "req-see", "screensee"); + usleep(200000); + ha_client_process(client); + + /* handler 已被调用,且返回了 data URL(无法直接验证回执内容,但 handler 已执行) */ + ha_client_stop(client); + ha_client_destroy(client); + mock_server_stop(ms); + + TEST_END(); +} + +/* ---------- 16. info_json 设备信息 ---------- */ +static void test_info_json(void) { + TEST_BEGIN("Device info_json: included in hello message"); + + mock_server_t *ms = mock_server_start(19901); + TEST_ASSERT(ms != NULL, "mock server start failed"); + + 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[] = {"camera", NULL}; + ha_cmd_handler_def_t handlers[] = { + {.command = "camerasue", .handler = test_handler_camerasue}, + {.command = NULL}, + }; + + ha_config_t config = { + .transport = transport, + .server = "127.0.0.1:19901", + .token = "test-token", + .device = { + .device_id = "info-dev", + .name = "Info Test", + .kind = "camera", + .caps = caps, + .info_json = "{\"chip\":\"ESP32-S3\",\"psram\":8}", + }, + .handlers = handlers, + .ping_interval = 30, + }; + + ha_client_t *client = ha_client_new(&config); + ha_status_t st = ha_client_start(client); + TEST_ASSERT(st == HA_OK, "client start failed"); + usleep(200000); + + /* 连接成功,info_json 已通过 hello 消息发送 */ + ha_client_stop(client); + ha_client_destroy(client); + mock_server_stop(ms); + + TEST_END(); +} + +/* ================================================================== + * 主函数 + * ================================================================== */ +int main(void) { +#if !defined(_WIN32) && !defined(_WIN64) + signal(SIGPIPE, SIG_IGN); +#endif + + printf("========================================\n"); + printf(" ha_remotedevice 全面测试\n"); + printf("========================================\n\n"); + + /* ---- JSON 解析/构建 ---- */ + printf("[JSON]\n"); + test_json_parser(); + test_json_int(); + test_json_builder(); + test_json_array_builder(); + + /* ---- 工具函数 ---- */ + printf("\n[Utilities]\n"); + test_parse_homeagent(); + test_parse_json(); + test_base64(); + + /* ---- WS 协议 ---- */ + printf("\n[WebSocket]\n"); + test_ws_frame(); + + /* ---- SDK 核心 ---- */ + printf("\n[SDK Core]\n"); + test_version(); + test_client_lifecycle(); + test_command_dispatch(); + + /* ---- 协议功能 ---- */ + printf("\n[Protocol]\n"); + test_event_report(); + test_status_report(); + test_binary_chunked(); + test_unsupported_command(); + test_speech_receive(); + test_binary_via_handler(); + + /* ---- 扩展命令 ---- */ + printf("\n[Extended Commands]\n"); + test_computeruse_dispatch(); + test_clipboard_dispatch(); + test_screensee_data_url(); + test_info_json(); + + /* ---- 汇总 ---- */ + printf("\n========================================\n"); + printf(" 结果: %d passed, %d failed, %d skipped\n", + tests_passed, tests_failed, tests_skipped); + printf("========================================\n"); + + return tests_failed > 0 ? 1 : 0; +} \ No newline at end of file diff --git a/sdk/plugin.go b/sdk/plugin.go index 1378eaf..f3cb0b1 100644 --- a/sdk/plugin.go +++ b/sdk/plugin.go @@ -147,6 +147,17 @@ type EventSubscriber interface { 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. type StageScope int @@ -200,6 +211,7 @@ type PluginSDK struct { sett SettingsAPI social SocialAPI events EventSubscriber + plgMgr PluginMgrAPI autoRestart bool @@ -347,6 +359,13 @@ func (s *PluginSDK) SetLLMAPI(llm LLMAPI) { s.llm = llm } func (s *PluginSDK) SetSocialAPI(social SocialAPI) { s.social = social } 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 ---- // InjectInterruptText injects a text interrupt that can preempt current LLM processing. diff --git a/tools/plugindev/cmd_init.go b/tools/plugindev/cmd_init.go index 921b97b..cbe2df0 100644 --- a/tools/plugindev/cmd_init.go +++ b/tools/plugindev/cmd_init.go @@ -69,18 +69,45 @@ type TemplateData struct { func cmdInit(args []string) { if len(args) < 1 { - fmt.Println("Usage: plugindev init [--lua]") + fmt.Println("Usage: plugindev init [--lua] [--type remotedevice]") os.Exit(1) } name := args[0] isLua := false + isRemoteDevice := false for _, a := range args[1:] { switch a { case "--lua": 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 if _, err := os.Stat(dir); !os.IsNotExist(err) { @@ -192,6 +219,57 @@ func detectSDKInfo() (modulePath, goVersion, sdkPath, sdkVersion string) { 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) { tmpl, err := template.New("").Parse(content) if err != nil { diff --git a/tools/plugindev/main.go b/tools/plugindev/main.go index 73c5206..cdc1015 100644 --- a/tools/plugindev/main.go +++ b/tools/plugindev/main.go @@ -30,15 +30,20 @@ func help() { fmt.Print(`HomeAgent Plugin Dev Tool Usage: - plugindev init Scaffold a new plugin project - plugindev build [flags] Compile and package plugin - plugindev clean Clean build/dist artifacts - plugindev debug [dir] Interpret and debug plugin source - plugindev sdk Manage SDK versions + plugindev init Scaffold a new plugin project + plugindev init --lua Create Lua plugin + plugindev init --type remotedevice + Create C remote device adapter + plugindev build [flags] Compile and package plugin + plugindev clean Clean build/dist artifacts + plugindev debug [dir] Interpret and debug plugin source + plugindev sdk Manage SDK versions Flags: --outdir Output directory (default: dist) --target Target OS/arch (e.g. linux/amd64), repeatable --lua Create Lua plugin (for init) + --type Project type: "remotedevice" (for init) + -t Alias for --type `) } diff --git a/tools/plugindev/templates.go b/tools/plugindev/templates.go index 8c7920f..9e1eaea 100644 --- a/tools/plugindev/templates.go +++ b/tools/plugindev/templates.go @@ -405,6 +405,10 @@ enum { CORE_SETTINGS_DUMP = 44, CORE_SETTINGS_PLUGINS = 45, 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 @@ -519,6 +523,7 @@ func buildPluginSDK(name string) *sdk.PluginSDK { base.SetLLMAPI(dispatchLLM{}) base.SetSocialAPI(dispatchSocial{}) base.SetTextMemoryAPI(dispatchTextMemory{}) + base.SetPluginMgrAPI(dispatchPluginMgr{}) base.SetInputChannelRegistrar( func(name string, def sdk.ChannelDef) error { defJSON, _ := json.Marshal(def) @@ -577,6 +582,31 @@ func (dispatchSocial) ListPersons() ([]string, error) { r, e := callString(40, " type dispatchTextMemory struct{} 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) ---- type dispatchSettings struct{} @@ -831,6 +861,353 @@ PluginAPI* plugin_init(void) { } ` +// ============================================================ +// Remote Device Adapter Templates +// ============================================================ + +const tmplRemoteDeviceMain = `#include +#include +#include + +#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 +#include +#include +#include +#include + +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}} {{.Plg.Description}}