diff --git a/.gitlab-ci.yml b/.gitlab-ci.yml index 0632b77..0d704ba 100644 --- a/.gitlab-ci.yml +++ b/.gitlab-ci.yml @@ -91,11 +91,12 @@ test:memory-leak: # tai_log_module_zero_tests, tai_integration_tests # iot-client : iot_cipher_test, iot_ai_ctrl_test # tuya-ble : tuya_ble_test + # biz-client : biz_ai_role_test (public ATOP boundary faked) # rtc-client is a prebuilt closed lib (headers + libstm.a) with no offline # test, so there is nothing to leak-check for it here. The mock-driven iot # tests are covered by the `test` job; under valgrind their Python # handshakes would time out. - for t in tai_unit_tests tai_log_level_zero_tests tai_log_module_zero_tests tai_integration_tests iot_cipher_test iot_ai_ctrl_test tuya_ble_test; do + for t in biz_ai_role_test tai_unit_tests tai_log_level_zero_tests tai_log_module_zero_tests tai_integration_tests iot_cipher_test iot_ai_ctrl_test tuya_ble_test; do echo "==================== valgrind: ${t} ====================" "$VG" "./${CMAKE_BUILD_DIR}/${t}" 2>&1 | tee "valgrind-${t}.log" rc=${PIPESTATUS[0]} diff --git a/CHANGELOG.md b/CHANGELOG.md index 8b95db7..d0f0e46 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -9,6 +9,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ### Added +- biz-client — typed AI role management, language/timbre queries, POSIX and ESP-IDF examples, and bilingual API documentation (#47). - iot-client — `iot_get_binding_status()` queries `tuya.device.info.sync` with saved credentials, without an IoT client (#44). - `iot_client_get_binding_status()` reuses an existing client's endpoint and TLS settings. - Examples — `unbind_demo` checks cloud binding status before client initialization/reconnect and after a removal notice (#44). diff --git a/CMakeLists.txt b/CMakeLists.txt index fc56e36..124836b 100644 --- a/CMakeLists.txt +++ b/CMakeLists.txt @@ -223,6 +223,25 @@ target_link_libraries(tuya_iot_client PUBLIC Threads::Threads ) +# ----------------------------------------------------------------------------- +# biz-client module (typed device-side cloud services) +# ----------------------------------------------------------------------------- +set(BIZ_CLIENT_DIR "${CMAKE_CURRENT_SOURCE_DIR}/modules/biz-client") +set(BIZ_CLIENT_SOURCES + "${BIZ_CLIENT_DIR}/src/biz_client.c" + "${BIZ_CLIENT_DIR}/src/ai_role.c" +) +add_library(tuya_biz_client STATIC ${BIZ_CLIENT_SOURCES}) +target_include_directories(tuya_biz_client + PUBLIC "${BIZ_CLIENT_DIR}/include" + PRIVATE "${cjson_source_dir}" +) +target_link_libraries(tuya_biz_client PUBLIC tuya_iot_client + PRIVATE ${AGENTIC_KIT_CJSON_TARGET} agentic_kit_common) +if(UNIX) + target_link_libraries(tuya_biz_client PRIVATE m) +endif() + # ----------------------------------------------------------------------------- # rtc-client module (prebuilt binary) # ----------------------------------------------------------------------------- @@ -270,6 +289,21 @@ target_link_libraries(tuya_ble PUBLIC # Tests # ----------------------------------------------------------------------------- if(AGENTIC_KIT_BUILD_TESTS AND AGENTIC_KIT_ENABLE_PROJECT_TESTS) + # Fake only the public ATOP boundary, so service parsing/ownership can be + # checked without network credentials or a subprocess. Register in ctest. + add_executable(biz_ai_role_test ${BIZ_CLIENT_SOURCES} + "${BIZ_CLIENT_DIR}/test/ai_role_test.c") + target_include_directories(biz_ai_role_test PRIVATE + "${BIZ_CLIENT_DIR}/include" "${IOT_CLIENT_DIR}/include" + "${PAL_DIR}" "${COMMON_DIR}" "${cjson_source_dir}") + target_compile_options(biz_ai_role_test PRIVATE -Wall -Wextra -Werror) + target_link_libraries(biz_ai_role_test PRIVATE + ${AGENTIC_KIT_CJSON_TARGET} agentic_kit_common) + if(UNIX) + target_link_libraries(biz_ai_role_test PRIVATE m) + endif() + add_test(NAME biz_ai_role_test COMMAND biz_ai_role_test) + set(AI_TCP_TESTS_DIR "${CMAKE_CURRENT_SOURCE_DIR}/modules/rtc-tcp-client/test") set(IOT_CLIENT_TESTS_DIR "${CMAKE_CURRENT_SOURCE_DIR}/modules/iot-client/test") set(TUYA_BLE_TESTS_DIR "${CMAKE_CURRENT_SOURCE_DIR}/modules/tuya-ble/test") diff --git a/CONTEXT-MAP.md b/CONTEXT-MAP.md index 0e0d35e..bd1485b 100644 --- a/CONTEXT-MAP.md +++ b/CONTEXT-MAP.md @@ -5,6 +5,8 @@ and decisions. ## Contexts +- [Business Client](./modules/biz-client/CONTEXT.md) — typed device-side cloud + business services, initially AI role management and language/timbre queries. - [IoT Client](./modules/iot-client/CONTEXT.md) — device ↔ Tuya cloud: activation, the Data Point model, MQTT reporting/downlink. ADRs: `modules/iot-client/docs/adr/`. - [RTC TCP Client](./modules/rtc-tcp-client/CONTEXT.md) — device ↔ Tuya AI Foundation @@ -16,6 +18,8 @@ and decisions. ## Relationships +- **Business Client → IoT Client**: uses only `iot_atop_call()` and its public + response-release contract. It does not depend on RTC or own application tasks. - **IoT Client → RTC TCP Client**: the IoT Client obtains an AI session token (`iot_client_get_session_token`) that the RTC client uses to open a Session. Applications may also keep MQTT active as an independent control path: diff --git a/README.md b/README.md index ac35b55..9b5d972 100644 --- a/README.md +++ b/README.md @@ -19,6 +19,7 @@ | RTC TCP Client | `tuya_ai.h` | tRTC(tuya自研RTC协议) TCP 实现,开源实现,PAL 可移植 | | RTC Client | `stm_open.h` | tRTC(tuya自研RTC协议) UDP 实现,预编译静态库形式 | | IoT Client | `iot_client.h`、`iot_dp.h`、`iot_ota.h` | 设备激活、MQTT 连接、会话令牌获取;数据点(DP)管理(schema 校验 / 缓存 / 上下行 / 持久化);OTA 固件升级(云端协议) | +| Business Client | `tuya_biz_client.h`、`tuya_ai_role.h` | 设备侧云端业务 API:AI 角色查询、切换、编辑、删除,以及语言和音色查询;复用 IoT ATOP,PAL 可移植 | | Tuya BLE | `tuya_ble_prov.h` | BLE 蓝牙配网(ESP-IDF) | ## 环境要求 diff --git a/README_en.md b/README_en.md index 923a83d..33a5a10 100644 --- a/README_en.md +++ b/README_en.md @@ -20,6 +20,7 @@ Multimodal device-side SDK for connecting smart hardware to the Tuya AI platform | RTC Client | `stm_open.h` | tRTC (Tuya RTC protocol), UDP implementation, pre-compiled static library | | IoT Client | `iot_client.h`, `iot_dp.h`, `iot_ota.h` | Device activation, MQTT, session token; Data Point (DP) management (schema validation / cache / up- & downlink / persistence); OTA firmware upgrade (cloud protocol) | | Tuya BLE | `tuya_ble_prov.h` | BLE provisioning (ESP-IDF) | +| Business Client | `tuya_biz_client.h`, `tuya_ai_role.h` | Typed cloud business APIs: AI role queries, selection, editing and deletion, plus language/timbre queries; uses public IoT ATOP and PAL | ## Prerequisites diff --git a/docs-site/docs/guides/ai-role-management.md b/docs-site/docs/guides/ai-role-management.md new file mode 100644 index 0000000..ae40178 --- /dev/null +++ b/docs-site/docs/guides/ai-role-management.md @@ -0,0 +1,128 @@ +--- +title: 设备端 AI 角色管理 +sidebar_label: AI 角色管理 +--- + +# 设备端 AI 角色管理 + +`biz-client` 为设备提供有类型的云端业务 API。第一组能力是 AI 角色管理:查询当前角色和角色列表、切换角色、编辑名称/语言/音色/语速、删除自定义角色。带屏设备可以绑定自己的列表和编辑页面,无屏设备也可以通过按键或串口调用。 + +本模块依赖 `iot-client` 的公开 `iot_atop_call()`。签名、加密和 HTTPS 由 IoT Client 处理,业务调用不需要再次提供设备密钥,也不依赖 RTC TCP Client。 + +## 接入前提 {#prerequisites} + +- 已调用 `iot_init()` 或 `iot_init_default()`,并持有已激活的 `iot_client_t`;MQTT 连接不是角色 ATOP 请求的前提。 +- 设备系统时间已校准,客户端配置了正确区域和 CA 证书或平台证书包。模块自动在请求体中加入当前 POSIX 秒数 `t`;时间明显未初始化时返回 `OPRT_UNINITIALIZED`,不发送请求。 +- 产品在云端有可用角色。可访问范围、语言和音色以实际设备返回为准。 +- ATOP 响应缓冲足够容纳角色列表。默认 `AGENTIC_KIT_RESPONSE_BUFFER_SIZE=4096` 同时容纳 HTTP 头和加密正文,解密后的 JSON 余量更小。模板列表不能分页时,客户应根据真实响应和设备内存增加此编译配置,例如 32768;它必须作用于编译 `iot-client` 的目标,不能只定义在应用源文件中。 + +## 创建业务客户端 {#client} + +```c +#include "tuya_biz_client.h" +#include "tuya_ai_role.h" + +tuya_biz_client_t *biz = NULL; +int rc = tuya_biz_client_create(iot, "zh", &biz); +``` + +`iot` 是应用已有的客户端。第二个参数 `request_lang` 决定角色名称等展示字段的语言,`NULL` 默认为 `zh`;它不同于角色回复语言。业务客户端借用 IoT Client 和其 PAL,退出时先释放列表、销毁业务客户端,再销毁 IoT Client。 + +POSIX 工程链接 `tuya_biz_client`;仓库自带的 ESP-IDF `agentic_kit` 组件已包含该模块源文件和公开头文件。模块不需要 PSRAM、NVS、LVGL 或特定芯片。 + +## 查询与切换 {#query-and-bind} + +以下示例必须在应用工作任务中执行: + +```c +tuya_ai_role_list_t templates = {0}; +iot_atop_rejection_t rejection = {0}; +int rc = tuya_ai_role_list_templates(biz, &templates, &rejection); +if (rc == OPRT_OK && templates.count > 0) { + rc = tuya_ai_role_bind(biz, templates.items[0].type, + templates.items[0].role_id, &rejection); +} +tuya_ai_role_list_free(biz, &templates); + +/* 绑定成功后回读,不以 UI 提示代替云端确认。 */ +tuya_ai_role_t bound = {0}; +if (rc == OPRT_OK) { + rc = tuya_ai_role_get_bound(biz, &bound, &rejection); +} +``` + +使用列表返回的 `role_id` 和类型,不根据名称猜 ID。`template_id` 用于关联模板来源,不能代替接口要求的角色 ID。 + +自定义角色和音色支持显式分页: + +```c +tuya_ai_role_list_t customs = {0}; +rc = tuya_ai_role_list_custom(biz, 1, 5, &customs, &rejection); +/* 消费 customs.items,然后释放。 */ +tuya_ai_role_list_free(biz, &customs); + +tuya_ai_timbre_list_t voices = {0}; +rc = tuya_ai_role_list_timbres(biz, "zh_ha", 1, 5, &voices, &rejection); +tuya_ai_timbre_list_free(biz, &voices); +``` + +`page_no`、`page_size` 必须为正数。`total_count=-1` 表示云端没有提供总数;空数组是正常结果。模块不会自动加载所有页。选择语言后,用云端返回的完整编码重新查询音色,例如保留 `zh_ha`,不要截断成 `zh`。 + +## 修改与删除 {#edit-and-delete} + +```c +/* selected 是已经获取的角色快照,且在调用期间有效。 */ +tuya_ai_role_edit_t edit = { + .name = "学习伙伴", + .set_speed = true, + .speed = 1.20, +}; +char effective_id[TUYA_AI_ROLE_ID_LEN] = {0}; +rc = tuya_ai_role_apply_edit(biz, &selected, &edit, + effective_id, &rejection); +``` + +| 目标 | 保存行为 | +| --- | --- | +| `TUYA_AI_ROLE_TEMPLATE=1` | 基于模板创建自定义角色,并发送 `needBind=true` 请求绑定;成功后返回新 ID | +| `TUYA_AI_ROLE_CUSTOM=0` | 更新原自定义角色,返回原 ID,不自动切换绑定 | +| `TUYA_AI_ROLE_SINGLE=2` | 可识别为查询结果;不接受本模块的多角色编辑和绑定操作 | + +字符串指针为 `NULL`、`set_speed=false` 表示不修改该字段。只提交真正修改的字段;空编辑、空字符串、过长值和非正/非有限语速会被拒绝。`speed` 在云端请求中是字符串,由模块转换;云端决定支持的语速范围。 + +当前可编辑字段仅有 `name`、`lang_code`、`timbre_id`、`speed`,不包含系统提示词、模型或知识库设置。 + +调用 `tuya_ai_role_delete_custom(biz, role_id, &rejection)` 删除自定义角色前,应用应确认该 ID 来自自定义列表。不要提交模板 ID;删除当前角色前先绑定其他可用角色并回读确认。模块不提供删除后的自动回退策略。 + +## 调度与内存 {#scheduling-and-memory} + +SDK 不自动创建线程、不重试、不缓存。每次角色操作都同步阻塞到 HTTPS 请求返回。应用在自己的工作任务中串行调用,并协调 IoT 客户端销毁;不要从 UI、MQTT 或 TAI 接收回调直接调用网络接口。 + +异步消息必须复制参数内容。`tuya_ai_role_edit_t` 内是借用指针,不能直接把指向 UI 临时缓冲的结构体排队。ESP-IDF 示例提供按值复制的 `role_demo_job_t`。 + +| 分配结果 | 释放接口 | +| --- | --- | +| 模板/自定义角色列表 | `tuya_ai_role_list_free(biz, &list)` | +| 语言列表 | `tuya_ai_language_list_free(biz, &list)` | +| 音色列表 | `tuya_ai_timbre_list_free(biz, &list)` | +| 业务客户端 | `tuya_biz_client_destroy(biz)` | + +查询前将输出清零;再次使用输出变量前先释放旧列表。释放时必须使用分配它的同一个、仍有效的业务客户端,列表指针不能在释放后交给 UI。所有容量单位为字节,包含结尾 `NUL`;云端返回字符串过长时返回 `OPRT_INVALID_RESULT`,不会截断 ID 或语言编码。 + +角色缓存、头像 HTTP 下载/解码和 UI 由应用实现。缓存只能用于展示,联网后应回读云端;设备解绑/换用户时应清理业务缓存。 + +## 错误与对话生效 {#errors-and-effect} + +所有接口返回 `OPRT_*`。可选 `iot_atop_rejection_t` 在调用入口清零,`OPRT_ATOP_BUSINESS_ERROR` 时携带云端 `code`、`msg`;传输错误保持原返回码。成功的云端信封也必须具有符合接口形状的结果,缺失/null/false 或格式错误返回 `OPRT_INVALID_RESULT`。 + +不自动重试模板创建:超时可能意味着云端已创建并绑定,应先查询当前绑定和自定义列表,避免重复创建。写入已成功但回读失败时,应分别展示“变更已接受”和“状态待刷新”。 + +云端绑定成功不等于已在当前 AI Session 生效。空闲时切换后,先核对绑定,再发起新一轮真实语音验证角色、语言及音色;同时验证重建 Session 的情况。是否需要新 Session 由客户云端配置和实际对话结果决定,由应用会话管理任务协调,`biz-client` 不会自行断开 RTC。 + +## 示例与验收 {#examples-and-acceptance} + +- POSIX:`examples/posix/ai/role-management`,提供查询、绑定、编辑、删除的命令行入口,真实变更只在显式命令下执行。 +- ESP-IDF:`examples/esp-idf/ai/role-management`,可接入已有设备工程的组件,包含工作队列和退出流程;不是独立配网固件。 +- API、ATOP 对照及完整签名见 [Business Client 参考](../reference/biz-client.md)。 + +至少验收模板/自定义查询、两种角色切换、模板修改产生新 ID、自定义修改保留 ID、未修改字段保留、语言和音色匹配、自定义删除、网络失败不误报成功、退出后无悬空客户端,以及新一轮实际语音效果。 diff --git a/docs-site/docs/reference/biz-client.md b/docs-site/docs/reference/biz-client.md new file mode 100644 index 0000000..a0b6b82 --- /dev/null +++ b/docs-site/docs/reference/biz-client.md @@ -0,0 +1,69 @@ +--- +title: Business Client API 参考 +sidebar_label: Business Client +--- + +# Business Client API 参考 + +模块目录 `modules/biz-client`,POSIX 构建目标 `tuya_biz_client`,公开头文件 `tuya_biz_client.h`、`tuya_ai_role.h`。接入步骤见 [AI 角色管理](../guides/ai-role-management.md)。 + +## 客户端和生命周期 {#lifecycle} + +```c +int tuya_biz_client_create(iot_client_t *iot, const char *request_lang, + tuya_biz_client_t **out); +void tuya_biz_client_destroy(tuya_biz_client_t *client); +``` + +借用 IoT Client 及其 PAL。`request_lang=NULL` 默认 `zh`,非空值最多 31 字节。创建失败时 `*out=NULL`。先 `iot_init`,再创建;所有结果必须在销毁客户端前释放,所有调用必须在销毁 IoT Client 前完成。多实例可独立持有客户端和展示语言;调用方负责串行调度。 + +## 角色 API {#role-api} + +下表省略共同首参 `tuya_biz_client_t *` 和末尾可选参数 `iot_atop_rejection_t *`,完整声明以公开头文件为准。 + +| API | 中间参数 | ATOP v1.0 | +| --- | --- | --- | +| `tuya_ai_role_get_bound` | `tuya_ai_role_t *out` | `thing.ai.agent.role.get-bind-role` | +| `tuya_ai_role_list_templates` | `tuya_ai_role_list_t *out` | `thing.ai.agent.role.role-template.list` | +| `tuya_ai_role_list_custom` | `int page_no, int page_size, tuya_ai_role_list_t *out` | `thing.ai.agent.role.custom-role.page` | +| `tuya_ai_role_bind` | `tuya_ai_role_type_t type, const char *role_id` | `thing.ai.agent.role.bind-with-role` | +| `tuya_ai_role_apply_edit` | `const tuya_ai_role_t *cur, const tuya_ai_role_edit_t *edit, char out_id[TUYA_AI_ROLE_ID_LEN]` | `thing.ai.agent.role.custom-role.create-from-template` 或 `thing.ai.agent.role.custom-role.update` | +| `tuya_ai_role_delete_custom` | `const char *role_id` | `thing.ai.agent.role.custom-role.delete` | +| `tuya_ai_role_list_languages` | `tuya_ai_language_list_t *out` | `thing.ai.agent.config.languages.list` | +| `tuya_ai_role_list_timbres` | `const char *lang, int page_no, int page_size, tuya_ai_timbre_list_t *out` | `thing.ai.timbre.market.page` | + +每个请求加入 `t`;列表查询还携带展示语言 `requestLang`。音色查询通过 `lang` 筛选,`NULL` 表示不过滤。分页接口不会自动继续请求下一页。 + +## 数据类型 {#types} + +`tuya_ai_role_t` 包含 `role_id`、`template_id`、`type`、`name`、`description`、`image_url`、`lang_code`、`lang_name`、`timbre_id`、`timbre_name` 和 `speed`。列表接口按来源设置类型,当前绑定查询读取云端类型;缺失类型按 `SINGLE` 处理。 + +`tuya_ai_role_edit_t` 包含可选 `name`、`lang_code`、`timbre_id` 指针,以及 `set_speed` 和 `speed`。模板分支创建并请求绑定,自定义分支原地更新。`out_id` 至少有 `TUYA_AI_ROLE_ID_LEN` 字节,失败时首字节清零。无修改时应跳过调用。 + +角色和音色列表包含 `items`、`count`、`total_count`,总数未知为 -1。语言列表包含 `items`、`count`。音色的 `support_langs` 是完整语种编码拼接的逗号串,仅用于展示;筛选仍由云端执行。 + +响应列表接受裸数组,或 `datas`、`list`、`records`、`rows`、`content`、`data`、`items` 中的数组。未知形状不猜测。角色 ID、语言编码、音色 ID 必须存在且非空;过长字段使整个结果失败,不返回半份列表。 + +绑定、删除和更新的确认结果保持与现有 TuyaOpen 角色实现兼容:ATOP 信封成功后,将非 null、非 false 的 JSON 结果视为不透明确认,不假定它必须是 Boolean。模板创建则必须返回非空的新 ID 字符串;是否正确绑定仍需回读确认。 + +## 释放与错误 {#release-and-errors} + +```c +void tuya_ai_role_list_free(tuya_biz_client_t *, tuya_ai_role_list_t *); +void tuya_ai_language_list_free(tuya_biz_client_t *, tuya_ai_language_list_t *); +void tuya_ai_timbre_list_free(tuya_biz_client_t *, tuya_ai_timbre_list_t *); +``` + +使用原分配上下文释放,释放后列表清零。查询失败时输出清空。客户端销毁不是列表批量释放器,调用方先释放每份结果。 + +| 返回码 | 含义 | +| --- | --- | +| `OPRT_OK` | 接口结果有效;对话效果仍需实际验证 | +| `OPRT_INVALID_PARAMETER` | 空/非法参数、无改动、非法类型或分页参数 | +| `OPRT_UNINITIALIZED` | 设备未激活或系统时间明显未初始化 | +| `OPRT_MALLOC_FAILED` | PAL/cJSON 申请内存失败 | +| `OPRT_INVALID_RESULT` | 云端结果缺失、形状错误、必要 ID 缺失或字段过长 | +| `OPRT_ATOP_BUSINESS_ERROR` | 云端拒绝,读取可选 `rejection.code/msg` | +| 其他 `OPRT_*` | ATOP 传输错误原样返回 | + +删除接口不会先验证角色来源;应用只能提交已确认的自定义 ID。模块不下载头像、不保存 NVS、不调用 RTC,不自动重试任何请求。 diff --git a/docs-site/i18n/en/docusaurus-plugin-content-docs/current/guides/ai-role-management.md b/docs-site/i18n/en/docusaurus-plugin-content-docs/current/guides/ai-role-management.md new file mode 100644 index 0000000..c0400ae --- /dev/null +++ b/docs-site/i18n/en/docusaurus-plugin-content-docs/current/guides/ai-role-management.md @@ -0,0 +1,163 @@ +--- +title: Device AI role management +sidebar_label: AI role management +--- + +# Device AI role management + +`biz-client` provides typed device-side cloud business APIs. Its first feature +set queries role templates, custom roles and the bound role; selects roles; +edits name, reply language, timbre and speed; and deletes custom roles. +Applications supply their own display, buttons or serial UI. + +It uses the public `iot_atop_call()` API. IoT Client owns credentials, signing, +encryption and HTTPS. Business Client has no RTC TCP Client dependency. + +## Prerequisites {#prerequisites} + +- Call `iot_init()` or `iot_init_default()` first and supply an activated + `iot_client_t`. A connected MQTT session is not required for role ATOP calls. +- Synchronize system time and configure the correct region and CA certificate + or platform certificate bundle. Requests include POSIX seconds in `t`. + An obviously uninitialized clock returns `OPRT_UNINITIALIZED` before sending. +- Configure available roles in the cloud; availability and access depend on + the device/product identity. +- Size `AGENTIC_KIT_RESPONSE_BUFFER_SIZE` for actual lists. Its default 4096 + bytes covers HTTP headers and the encrypted response together, leaving less + space for decrypted JSON. An unpaged template list may require a larger + value, for example 32768, subject to measured memory. Apply the definition + to the target compiling iot-client, not just an application source file. + +## Create a Business Client {#client} + +```c +#include "tuya_biz_client.h" +#include "tuya_ai_role.h" + +tuya_biz_client_t *biz = NULL; +int rc = tuya_biz_client_create(iot, "en", &biz); +``` + +The existing IoT Client is borrowed. `request_lang` selects the language of +display labels, not the role's reply language; NULL defaults to `zh`. +Free all lists, destroy the Business Client, then destroy the IoT Client. +POSIX consumers link `tuya_biz_client`. The bundled ESP-IDF `agentic_kit` +component already includes its sources and public headers. PSRAM, NVS, LVGL +and any particular board are not required by the module. + +## Query and bind {#query-and-bind} + +Run these synchronous calls in an application-owned worker: + +```c +tuya_ai_role_list_t templates = {0}; +iot_atop_rejection_t rejection = {0}; +int rc = tuya_ai_role_list_templates(biz, &templates, &rejection); +if (rc == OPRT_OK && templates.count > 0) { + rc = tuya_ai_role_bind(biz, templates.items[0].type, + templates.items[0].role_id, &rejection); +} +tuya_ai_role_list_free(biz, &templates); + +tuya_ai_role_t bound = {0}; +if (rc == OPRT_OK) { + rc = tuya_ai_role_get_bound(biz, &bound, &rejection); +} +``` + +Use returned IDs and types, never guess from display names or substitute a +`template_id` for `role_id`. Read back the bound role after a successful change. +Custom roles and timbres have explicit pagination: + +```c +tuya_ai_role_list_t customs = {0}; +rc = tuya_ai_role_list_custom(biz, 1, 5, &customs, &rejection); +tuya_ai_role_list_free(biz, &customs); + +tuya_ai_timbre_list_t voices = {0}; +rc = tuya_ai_role_list_timbres(biz, "zh_ha", 1, 5, &voices, &rejection); +tuya_ai_timbre_list_free(biz, &voices); +``` + +Page and size must be positive. `total_count=-1` means absent metadata; +an empty array is valid. No implicit all-pages load occurs. Pass the full +cloud language code, including dialect suffixes, to query matching timbres. + +## Edit and delete {#edit-and-delete} + +```c +tuya_ai_role_edit_t edit = { + .name = "Study buddy", .set_speed = true, .speed = 1.20, +}; +char effective_id[TUYA_AI_ROLE_ID_LEN] = {0}; +rc = tuya_ai_role_apply_edit(biz, &selected, &edit, + effective_id, &rejection); +``` + +`selected` is a live role snapshot. Editing `TUYA_AI_ROLE_TEMPLATE=1` +creates a custom role with `needBind=true` and returns its new ID. +Editing `TUYA_AI_ROLE_CUSTOM=0` updates that role in place, returns the same +ID, and does not change the current binding. `TUYA_AI_ROLE_SINGLE=2` may +appear in query results but is not accepted by multi-role bind/edit APIs. + +NULL string fields and `set_speed=false` preserve cloud values. Empty edits, +empty strings, overlong fields and nonfinite/nonpositive speeds are rejected. +The module serializes speed as a string; the cloud determines supported +ranges. Editable fields are name, language, timbre and speed, not system +prompts, models or knowledge bases. + +Before `tuya_ai_role_delete_custom(biz, role_id, &rejection)`, confirm the ID +came from a custom list. Do not submit template IDs. Select and confirm a +different role before deleting the currently bound role; no automatic fallback +is defined by this module. + +## Scheduling and memory {#scheduling-and-memory} + +The SDK starts no task, retries nothing and caches nothing. All calls block on +HTTPS. Serialize them in an application worker and coordinate IoT teardown. +Never issue them from UI, MQTT or TAI receive callbacks. + +Copy values into queued messages. `tuya_ai_role_edit_t` holds borrowed +pointers, so do not queue pointers to temporary UI strings. The ESP-IDF example +uses value-only `role_demo_job_t` messages. + +Release role lists with `tuya_ai_role_list_free`, languages with +`tuya_ai_language_list_free`, and timbres with `tuya_ai_timbre_list_free`, +using the same live Business Client that allocated them. Initialize outputs to +zero and free old lists before reuse. Do not retain UI pointers after freeing. +Public string capacities are bytes including NUL; oversized cloud strings +return `OPRT_INVALID_RESULT` rather than silently truncating IDs or codes. + +NVS caching, avatar fetching/decoding and UI remain application-owned. Cached +roles are display hints, not proof of current cloud binding; refresh online +and clear them when a device is unbound or transferred to another user. + +## Errors and conversation effects {#errors-and-effect} + +APIs return `OPRT_*` and clear their optional `iot_atop_rejection_t` on entry. +`OPRT_ATOP_BUSINESS_ERROR` preserves cloud `code`/`msg`; transport failures +retain their original code. An accepted envelope still needs a valid result. +Missing/null/false results or malformed shapes yield `OPRT_INVALID_RESULT`. + +Do not blindly retry template creation after a timeout: the cloud may already +have created and bound it. Query bound/custom roles first. Distinguish an +accepted write from a failed subsequent refresh in the UI. + +A confirmed binding does not prove the new role affects an existing AI +Session. Switch while idle, read the binding back, then verify a real new +voice Event, including language and timbre. Also check a newly established +Session. The application owns any required Session reconstruction; Business +Client never disconnects RTC itself. + +## Examples and acceptance {#examples-and-acceptance} + +- `examples/posix/ai/role-management`: explicit CLI commands for queries and + mutations; no implicit destructive operation. +- `examples/esp-idf/ai/role-management`: reusable worker/queue component for an + existing device project, not standalone provisioning firmware. +- API and cloud interface mapping: [Business Client reference](../reference/biz-client.md). + +Verify lists, both role types, template edits producing a new ID, custom edits +keeping their ID, omitted-field preservation, matching language/timbre, custom +deletion, honest failure reporting, safe teardown, and actual subsequent voice +behavior. diff --git a/docs-site/i18n/en/docusaurus-plugin-content-docs/current/reference/biz-client.md b/docs-site/i18n/en/docusaurus-plugin-content-docs/current/reference/biz-client.md new file mode 100644 index 0000000..51853b6 --- /dev/null +++ b/docs-site/i18n/en/docusaurus-plugin-content-docs/current/reference/biz-client.md @@ -0,0 +1,96 @@ +--- +title: Business Client API reference +sidebar_label: Business Client +--- + +# Business Client API reference + +Module `modules/biz-client`, POSIX target `tuya_biz_client`, public headers +`tuya_biz_client.h` and `tuya_ai_role.h`. +See [AI role management](../guides/ai-role-management.md) for integration. + +## Client and lifetime {#lifecycle} + +```c +int tuya_biz_client_create(iot_client_t *iot, const char *request_lang, + tuya_biz_client_t **out); +void tuya_biz_client_destroy(tuya_biz_client_t *client); +``` + +Borrows the IoT Client and its PAL. NULL `request_lang` defaults to `zh`; +nonempty values can occupy at most 31 bytes. Failure clears `*out`. +Initialize IoT first; free results before destroying Business Client and finish +all calls before IoT teardown. Instances keep separate contexts and label +languages; the application owns serialized scheduling. + +## Role APIs {#role-api} + +All operations take a Business Client first and optional +`iot_atop_rejection_t *` last. Complete signatures are in the public header. + +| API | Output or operation | ATOP v1.0 | +| --- | --- | --- | +| `tuya_ai_role_get_bound` | `tuya_ai_role_t` | `thing.ai.agent.role.get-bind-role` | +| `tuya_ai_role_list_templates` | `tuya_ai_role_list_t` | `thing.ai.agent.role.role-template.list` | +| `tuya_ai_role_list_custom` | explicit page/size, role list | `thing.ai.agent.role.custom-role.page` | +| `tuya_ai_role_bind` | role type and ID | `thing.ai.agent.role.bind-with-role` | +| `tuya_ai_role_apply_edit` | current role, edit, output ID | `thing.ai.agent.role.custom-role.create-from-template` or `thing.ai.agent.role.custom-role.update` | +| `tuya_ai_role_delete_custom` | custom role ID | `thing.ai.agent.role.custom-role.delete` | +| `tuya_ai_role_list_languages` | `tuya_ai_language_list_t` | `thing.ai.agent.config.languages.list` | +| `tuya_ai_role_list_timbres` | full language code, page/size, timbre list | `thing.ai.timbre.market.page` | + +Every request includes `t`; list queries also include display `requestLang`. +Timbre queries use `lang`; NULL means unfiltered. No hidden next-page request. + +## Types {#types} + +`tuya_ai_role_t` has `role_id`, `template_id`, `type`, `name`, `description`, +`image_url`, `lang_code`, `lang_name`, `timbre_id`, `timbre_name`, `speed`. +List queries stamp the source role type. Bound-role queries read the cloud +type and default an omitted type to SINGLE. + +`tuya_ai_role_edit_t` has optional `name`, `lang_code`, `timbre_id` pointers, +plus `set_speed` and `speed`. Templates create and request binding; custom +roles update in place. `out_id` needs `TUYA_AI_ROLE_ID_LEN` bytes and is +cleared on failure. Skip calls when nothing changed. + +Role and timbre lists have `items`, `count`, `total_count` (-1 if unknown). +Language lists have `items` and `count`. Timbre `support_langs` is a +comma-separated display field preserving full codes; cloud filtering uses +the original language code. + +List responses may be arrays or arrays under `datas`, `list`, `records`, +`rows`, `content`, `data`, `items`. Unknown shapes are rejected. Required +role IDs, language codes and voice IDs must be nonempty; oversized fields +reject the whole result rather than returning a partial list. + +Bind, delete and update acknowledgements retain compatibility with the existing +TuyaOpen role implementation: after a successful ATOP envelope, a non-null, +non-false JSON result is opaque acknowledgement, not necessarily a Boolean. +Template creation specifically requires a nonempty new ID string. Read back +the binding to verify the intended role was selected. + +## Release and errors {#release-and-errors} + +```c +void tuya_ai_role_list_free(tuya_biz_client_t *, tuya_ai_role_list_t *); +void tuya_ai_language_list_free(tuya_biz_client_t *, tuya_ai_language_list_t *); +void tuya_ai_timbre_list_free(tuya_biz_client_t *, tuya_ai_timbre_list_t *); +``` + +Release through the original live context. Lists are zeroed after release; +failed queries leave empty outputs. Destroying the context does not free +outstanding result lists. + +| Return | Meaning | +| --- | --- | +| `OPRT_OK` | Valid result; actual conversation effects need separate validation | +| `OPRT_INVALID_PARAMETER` | Invalid/missing arguments, no edits, type or pagination | +| `OPRT_UNINITIALIZED` | Unactivated device or obviously uninitialized system time | +| `OPRT_MALLOC_FAILED` | PAL/cJSON allocation failed | +| `OPRT_INVALID_RESULT` | Missing/malformed result, missing required ID, oversized fields | +| `OPRT_ATOP_BUSINESS_ERROR` | Cloud rejection; inspect optional code/msg | +| Other `OPRT_*` | Original ATOP transport failure | + +Deletion does not query role provenance first; callers submit only confirmed +custom IDs. No avatar download, NVS, RTC calls or automatic retry occur. diff --git a/docs-site/sidebars.ts b/docs-site/sidebars.ts index b9bccc4..34ce5c3 100644 --- a/docs-site/sidebars.ts +++ b/docs-site/sidebars.ts @@ -57,6 +57,7 @@ const sidebars: SidebarsConfig = { 'guides/device-mcp', 'guides/ota-upgrade', 'guides/atop-generic-call', + 'guides/ai-role-management', 'guides/tls-cert-verification', 'guides/porting-to-new-platform', 'guides/compile-time-knobs', @@ -71,6 +72,7 @@ const sidebars: SidebarsConfig = { 'reference/rtc-tcp-client', 'reference/rtc-client', 'reference/iot-client', + 'reference/biz-client', ], }, 'get-authkey', diff --git a/examples/esp-idf/ai/role-management/CMakeLists.txt b/examples/esp-idf/ai/role-management/CMakeLists.txt new file mode 100644 index 0000000..3372ff2 --- /dev/null +++ b/examples/esp-idf/ai/role-management/CMakeLists.txt @@ -0,0 +1,7 @@ +# Reusable example component: add this directory to EXTRA_COMPONENT_DIRS in an +# existing activated device project. No app_main, WiFi config, or board driver. +idf_component_register( + SRCS "role_management.c" + INCLUDE_DIRS "." + REQUIRES agentic_kit freertos +) diff --git a/examples/esp-idf/ai/role-management/README.md b/examples/esp-idf/ai/role-management/README.md new file mode 100644 index 0000000..9b14571 --- /dev/null +++ b/examples/esp-idf/ai/role-management/README.md @@ -0,0 +1,63 @@ +# ESP-IDF 角色管理工作任务示例 + +这是接入已有设备工程的示例组件,不是独立整机固件。复用现有配网、校时和激活后的 `iot_client_t`,不引入 LVGL、相机、音频驱动或 NVS。云端角色、语言音色 API 位于 SDK 的 `biz-client` 模块。 + +## 工程接入 + +在工程顶层 `project()` 之前,将本目录加入 `EXTRA_COMPONENT_DIRS`,并继续包含仓库提供的 `examples/esp-idf/components`。应用组件声明 `REQUIRES agentic_kit role-management`。若复制源码到工程 `components/role_management`,则依赖名改为 `role_management`。 + +联网、激活并完成校时后,由应用管理任务初始化: + +```c +#include "role_management.h" + +static void on_role_done(role_demo_operation_t operation, int rc, + bool change_accepted, + const iot_atop_rejection_t *rejection, void *user_data) +{ + /* 这是角色 worker 回调。将结果按值投给 UI,不在这里调用 stop。 + * change_accepted=true 且 rc!=0 表示写入成功、回读失败,不能直接重试创建。 */ +} + +if (!role_management_start(iot, on_role_done, NULL)) { + /* 任务、队列或业务客户端初始化失败。 */ +} +``` + +可在工程的 `agentic_kit_config.h` 中调整 `AGENTIC_KIT_RESPONSE_BUFFER_SIZE`,或把编译定义加到编译 SDK 的组件目标。模板列表可能超过默认 4096 字节的 HTTP/密文共用缓冲;例如从 32768 开始结合实际数据测量。 + +## 投递操作 + +UI/按键回调只构造按值消息并调用非阻塞 `role_management_submit()`。返回 false 表示未启动、消息无效或队列满,显示忙碌/失败,不要直接提示操作成功。 + +```c +role_demo_job_t job = { + .operation = ROLE_DEMO_LOAD, .page_no = 1, .page_size = 5, +}; +bool accepted = role_management_submit(&job); +``` + +按查询日志/客户 UI 获取的真实 ID,构造绑定或编辑消息: + +```c +role_demo_job_t job = { + .operation = ROLE_DEMO_EDIT, + .type = TUYA_AI_ROLE_TEMPLATE, + .role_id = "ROLE_ID_FROM_CLOUD", + .set_name = true, .name = "学习伙伴", + .set_speed = true, .speed = 1.20, +}; +bool accepted = role_management_submit(&job); +``` + +支持 `ROLE_DEMO_LOAD`、`ROLE_DEMO_BIND`、`ROLE_DEMO_EDIT`、`ROLE_DEMO_DELETE`、`ROLE_DEMO_LANGUAGES`、`ROLE_DEMO_TIMBRES`。音色查询用 `lang` 和正数 `page_no/page_size`;保留完整语言编码。删除只接受自定义类型,并拒绝删除当前绑定角色。 + +worker 打印当前页列表与变更后绑定,完成回调提供返回码和云端拒绝原因。若需要向屏幕展示列表,应在消费列表、释放之前复制/转交数据,遵守 UI 的线程与锁约定。SDK 不替应用绘制页面。 + +## 生命周期与验收 + +示例为单实例,4 条队列消息和一个 16 KB 栈 worker;实际栈和内存需测量高水位。队列消息内所有字符串均按值复制,不保留 UI 临时指针。 + +设备解绑或销毁 IoT Client 前,先停止 UI/按键生产者,再由应用管理任务调用 `role_management_stop()`。它会等待队列及正在执行的 HTTPS 完成,释放业务客户端,最后才可销毁 IoT Client;不能从本 worker 或其完成回调调用 stop,也不能与 submit 并发竞态。 + +验收角色查询、绑定、两类编辑语义、删除、语言音色筛选,以及断网/队列满/退出。绑定成功后,用新一轮真实对话确认语言、音色和角色行为;SDK 不自动重建 TAI Session。 diff --git a/examples/esp-idf/ai/role-management/role_management.c b/examples/esp-idf/ai/role-management/role_management.c new file mode 100644 index 0000000..e7ff200 --- /dev/null +++ b/examples/esp-idf/ai/role-management/role_management.c @@ -0,0 +1,119 @@ +#include "role_management.h" +#include "freertos/FreeRTOS.h" +#include "freertos/queue.h" +#include "freertos/semphr.h" +#include "freertos/task.h" +#include "esp_log.h" +#include + +#define ROLE_DEMO_STOP ((role_demo_operation_t)-1) +static const char *TAG = "role-demo"; +static QueueHandle_t jobs; +static SemaphoreHandle_t stopped; +static tuya_biz_client_t *biz; +static role_demo_done_fn done; +static void *done_data; + +static int show_bound(iot_atop_rejection_t *err) +{ + tuya_ai_role_t role; + int rc = tuya_ai_role_get_bound(biz, &role, err); + if (!rc) ESP_LOGI(TAG, "bound: id=%s name=%s type=%d", role.role_id, role.name, role.type); + return rc; +} +static void worker(void *arg) +{ + (void)arg; role_demo_job_t job; + while (xQueueReceive(jobs, &job, portMAX_DELAY) == pdTRUE) { + if (job.operation == ROLE_DEMO_STOP) break; + iot_atop_rejection_t err = {0}; int rc = OPRT_INVALID_PARAMETER; + bool changed = false; + if (job.operation == ROLE_DEMO_LOAD) { + tuya_ai_role_list_t templates = {0}, customs = {0}; + rc = tuya_ai_role_list_templates(biz, &templates, &err); + if (!rc) rc = tuya_ai_role_list_custom(biz, job.page_no, job.page_size, &customs, &err); + if (!rc) { + for (size_t i = 0; i < templates.count; ++i) + ESP_LOGI(TAG, "template: %s %s", templates.items[i].role_id, templates.items[i].name); + for (size_t i = 0; i < customs.count; ++i) + ESP_LOGI(TAG, "custom: %s %s", customs.items[i].role_id, customs.items[i].name); + rc = show_bound(&err); + } + tuya_ai_role_list_free(biz, &templates); tuya_ai_role_list_free(biz, &customs); + } else if (job.operation == ROLE_DEMO_BIND) { + rc = tuya_ai_role_bind(biz, job.type, job.role_id, &err); changed = !rc; + } else if (job.operation == ROLE_DEMO_EDIT) { + tuya_ai_role_t role = { .type = job.type }; + memcpy(role.role_id, job.role_id, sizeof(role.role_id)); + tuya_ai_role_edit_t edit = { .name = job.set_name ? job.name : NULL, + .lang_code = job.set_lang ? job.lang : NULL, + .timbre_id = job.set_timbre ? job.timbre : NULL, + .set_speed = job.set_speed, .speed = job.speed }; + char id[TUYA_AI_ROLE_ID_LEN]; + rc = tuya_ai_role_apply_edit(biz, &role, &edit, id, &err); + if (!rc) ESP_LOGI(TAG, "saved role id=%s", id); + changed = !rc; + } else if (job.operation == ROLE_DEMO_DELETE && job.type == TUYA_AI_ROLE_CUSTOM) { + tuya_ai_role_t bound; + rc = tuya_ai_role_get_bound(biz, &bound, &err); + if (!rc && !strcmp(bound.role_id, job.role_id)) rc = OPRT_INVALID_PARAMETER; + else if (!rc) { rc = tuya_ai_role_delete_custom(biz, job.role_id, &err); changed = !rc; } + } else if (job.operation == ROLE_DEMO_LANGUAGES) { + tuya_ai_language_list_t list = {0}; + rc = tuya_ai_role_list_languages(biz, &list, &err); + if (!rc) for (size_t i = 0; i < list.count; ++i) + ESP_LOGI(TAG, "language: %s %s", list.items[i].code, list.items[i].name); + tuya_ai_language_list_free(biz, &list); + } else if (job.operation == ROLE_DEMO_TIMBRES) { + tuya_ai_timbre_list_t list = {0}; + rc = tuya_ai_role_list_timbres(biz, job.lang[0] ? job.lang : NULL, + job.page_no, job.page_size, &list, &err); + if (!rc) for (size_t i = 0; i < list.count; ++i) + ESP_LOGI(TAG, "timbre: %s %s", list.items[i].id, list.items[i].name); + tuya_ai_timbre_list_free(biz, &list); + } + if (changed) { + ESP_LOGI(TAG, "change accepted; refreshing bound role"); + rc = show_bound(&err); + if (rc) ESP_LOGW(TAG, "change accepted but refresh failed; query before retrying a write"); + } + ESP_LOGI(TAG, "operation=%d rc=%d cloud=%s %s", job.operation, rc, err.code, err.msg); + if (done) done(job.operation, rc, changed, &err, done_data); + } + tuya_biz_client_destroy(biz); biz = NULL; + xSemaphoreGive(stopped); + vTaskDelete(NULL); +} + +bool role_management_start(iot_client_t *iot, role_demo_done_fn cb, void *ud) +{ + if (jobs) return false; + if (tuya_biz_client_create(iot, "zh", &biz)) return false; + jobs = xQueueCreate(4, sizeof(role_demo_job_t)); + stopped = xSemaphoreCreateBinary(); done = cb; done_data = ud; + if (jobs && stopped && xTaskCreate(worker, "role-demo", 16384, NULL, 4, NULL) == pdPASS) return true; + if (jobs) vQueueDelete(jobs); + if (stopped) vSemaphoreDelete(stopped); + jobs = NULL; stopped = NULL; + tuya_biz_client_destroy(biz); biz = NULL; + return false; +} +bool role_management_submit(const role_demo_job_t *job) +{ + if (!jobs || !job || job->operation < ROLE_DEMO_LOAD || job->operation > ROLE_DEMO_TIMBRES) + return false; + /* All fixed arrays must be terminated before a worker may pass them on. */ + if (!memchr(job->role_id, 0, sizeof(job->role_id)) || + !memchr(job->name, 0, sizeof(job->name)) || !memchr(job->lang, 0, sizeof(job->lang)) || + !memchr(job->timbre, 0, sizeof(job->timbre))) return false; + return xQueueSend(jobs, job, 0) == pdTRUE; +} +void role_management_stop(void) +{ + if (!jobs) return; + role_demo_job_t job = { .operation = ROLE_DEMO_STOP }; + xQueueSend(jobs, &job, portMAX_DELAY); + xSemaphoreTake(stopped, portMAX_DELAY); + vQueueDelete(jobs); vSemaphoreDelete(stopped); + jobs = NULL; stopped = NULL; done = NULL; done_data = NULL; +} diff --git a/examples/esp-idf/ai/role-management/role_management.h b/examples/esp-idf/ai/role-management/role_management.h new file mode 100644 index 0000000..73e4b21 --- /dev/null +++ b/examples/esp-idf/ai/role-management/role_management.h @@ -0,0 +1,36 @@ +#pragma once +#include "tuya_ai_role.h" + +typedef enum { + ROLE_DEMO_LOAD, ROLE_DEMO_BIND, ROLE_DEMO_EDIT, ROLE_DEMO_DELETE, + ROLE_DEMO_LANGUAGES, ROLE_DEMO_TIMBRES +} role_demo_operation_t; + +/* Value-only message: safe to copy from a UI/keypad callback into a queue. */ +typedef struct { + role_demo_operation_t operation; + tuya_ai_role_type_t type; + char role_id[TUYA_AI_ROLE_ID_LEN]; + bool set_name, set_lang, set_timbre, set_speed; + char name[TUYA_AI_ROLE_NAME_LEN]; + char lang[TUYA_AI_ROLE_LANG_LEN]; + char timbre[TUYA_AI_ROLE_ID_LEN]; + double speed; + int page_no, page_size; /* LOAD custom list and TIMBRES; >0 */ +} role_demo_job_t; + +/* Runs on worker. Borrowed arguments expire on return; marshal UI updates. */ +/* change_accepted distinguishes a successful write followed by failed refresh: + * do not retry creation just because rc (refresh) is nonzero in that case. */ +typedef void (*role_demo_done_fn)(role_demo_operation_t, int rc, bool change_accepted, + const iot_atop_rejection_t *, void *user_data); + +/* Call once from app owner after activation/time sync, not a receive callback. + * One instance in this example; the SDK itself permits independent contexts. */ +bool role_management_start(iot_client_t *, role_demo_done_fn, void *user_data); +/* Nonblocking. false means not started/full/invalid: show busy, do not claim success. + * Do not race start/stop with producers. No network work happens here. */ +bool role_management_submit(const role_demo_job_t *); +/* Blocks until in-flight HTTPS finishes. First quiesce UI producers; call from + * owner (never worker/callback), and only then destroy the borrowed IoT client. */ +void role_management_stop(void); diff --git a/examples/esp-idf/components/agentic_kit/CMakeLists.txt b/examples/esp-idf/components/agentic_kit/CMakeLists.txt index 4c04d86..ec0bf8e 100644 --- a/examples/esp-idf/components/agentic_kit/CMakeLists.txt +++ b/examples/esp-idf/components/agentic_kit/CMakeLists.txt @@ -1,6 +1,10 @@ set(AK_DIR ${CMAKE_CURRENT_LIST_DIR}/../../../..) set(AK_SRCS + # biz-client: typed cloud business services, platform-independent + ${AK_DIR}/modules/biz-client/src/biz_client.c + ${AK_DIR}/modules/biz-client/src/ai_role.c + # rtc-tcp-client (TAI 2.1) ${AK_DIR}/modules/rtc-tcp-client/src/tai_attrs.c ${AK_DIR}/modules/rtc-tcp-client/src/tai_client.c @@ -52,6 +56,7 @@ set(AK_SRCS idf_component_register( SRCS ${AK_SRCS} INCLUDE_DIRS + ${AK_DIR}/modules/biz-client/include ${AK_DIR}/modules/rtc-tcp-client/include ${AK_DIR}/modules/iot-client/include ${AK_DIR}/modules/tuya-ble/include diff --git a/examples/posix/CMakeLists.txt b/examples/posix/CMakeLists.txt index 22f057c..9ee7254 100644 --- a/examples/posix/CMakeLists.txt +++ b/examples/posix/CMakeLists.txt @@ -51,6 +51,9 @@ add_library(quirc STATIC ) target_include_directories(quirc PUBLIC "${quirc_SOURCE_DIR}/lib") +# -- ai / role-management -------------------------------------------------- +add_subdirectory(ai/role-management) + # -- pair / api-activate ----------------------------------------------------- add_executable(activate_demo "${CMAKE_CURRENT_SOURCE_DIR}/pair/api-activate/activate_demo.c" diff --git a/examples/posix/ai/role-management/CMakeLists.txt b/examples/posix/ai/role-management/CMakeLists.txt new file mode 100644 index 0000000..637f08c --- /dev/null +++ b/examples/posix/ai/role-management/CMakeLists.txt @@ -0,0 +1,13 @@ +cmake_minimum_required(VERSION 3.20) +project(agentic_kit_role_example C) +set(CMAKE_C_STANDARD 99) +set(CMAKE_C_STANDARD_REQUIRED ON) +if(NOT TARGET tuya_biz_client) + set(AGENTIC_KIT_BUILD_EXAMPLES OFF CACHE BOOL "" FORCE) + set(AGENTIC_KIT_BUILD_TESTS OFF CACHE BOOL "" FORCE) + add_subdirectory("${CMAKE_CURRENT_LIST_DIR}/../../../.." + "${CMAKE_BINARY_DIR}/_agentic_kit" EXCLUDE_FROM_ALL) +endif() +add_executable(role_management_demo role_demo.c role_demo_main.c) +target_link_libraries(role_management_demo PRIVATE tuya_biz_client) +target_compile_options(role_management_demo PRIVATE -Wall -Wextra -Werror) diff --git a/examples/posix/ai/role-management/README.md b/examples/posix/ai/role-management/README.md new file mode 100644 index 0000000..fcfe134 --- /dev/null +++ b/examples/posix/ai/role-management/README.md @@ -0,0 +1,41 @@ +# AI 角色管理命令行示例 + +使用 `biz-client` 的角色、语言和音色 API,适用于 macOS/Linux。无需声卡、屏幕或 RTC 库。默认只显示帮助,只有显式命令才请求云端;编辑、绑定和删除会修改指定设备的真实云端角色。设备必须已正常激活,系统时间正确。 + +在 SDK 根目录单独编译此示例,避免加载其他示例的 QR/图像依赖: + +```sh +cmake -S examples/posix/ai/role-management -B build-role \ + -DPython3_EXECUTABLE="$(command -v python3)" \ + -DCMAKE_C_FLAGS=-DAGENTIC_KIT_RESPONSE_BUFFER_SIZE=32768 +cmake --build build-role --target role_management_demo -j 4 +./build-role/role_management_demo --help +``` + +Python 环境需要 SDK 构建依赖 `jsonschema`、`jinja2`。32768 是示例响应缓冲配置,不是所有产品的最低要求。模板列表不能分页时,应以真实最大响应量和内存余量调整;编译定义必须作用于 SDK 本身。 + +从安全的本地凭证加载方式设置 `TUYA_DEVICE_ID`、`TUYA_SECRET_KEY`、`TUYA_LOCAL_KEY` 和 `TUYA_CA_FILE`。凭证来自该设备激活结果,CA 文件为其区域云端的可信 PEM;可选 `TUYA_REGION` 为 AY/AZ/UE/EU/WE/IN/SG,默认 AY。不要把密钥写进源码或命令行参数。该工具设置 `skip_version_report=true`,不覆盖真实设备的固件版本记录,也不建立 MQTT。 + +```sh +./build-role/role_management_demo bound +./build-role/role_management_demo templates +./build-role/role_management_demo custom 1 5 +./build-role/role_management_demo languages +./build-role/role_management_demo timbres zh_ha 1 5 +``` + +从查询结果选择真实 ID 后,按需执行下面的变更命令。`ROLE_ID`、`CUSTOM_ID`、`VOICE_ID` 为占位符: + +```sh +./build-role/role_management_demo bind template ROLE_ID +./build-role/role_management_demo edit template ROLE_ID 'name=学习伙伴' speed=1.20 +./build-role/role_management_demo edit custom CUSTOM_ID lang=zh_ha timbre=VOICE_ID +./build-role/role_management_demo bind custom CUSTOM_ID +./build-role/role_management_demo delete custom CUSTOM_ID --confirm +``` + +模板编辑创建并请求绑定新自定义角色,日志会输出新 ID;自定义编辑只原地更新,不自动绑定。删除命令先回读当前绑定并拒绝删除当前角色,需先切换到其他角色。没有 `--confirm` 不执行删除。 + +成功日志包括列表、`saved role` 或回读的 `bound`。若云端已接受变更但回读失败,日志会单独指出,退出码非零;先查询,再决定是否重试写入。接口回读不等于 AI 实际生效,需要在设备上发起新一轮真实语音验证。 + +该示例不自动创建角色缓存、下载头像或重建 AI 会话。API 和线程/内存约束见文档站的 AI 角色管理指南和 Business Client 参考。 diff --git a/examples/posix/ai/role-management/role_demo.c b/examples/posix/ai/role-management/role_demo.c new file mode 100644 index 0000000..cbf2efe --- /dev/null +++ b/examples/posix/ai/role-management/role_demo.c @@ -0,0 +1,119 @@ +#include "role_demo.h" +#include +#include +#include +#include +#include + +void role_demo_usage(void) +{ + puts("role_management_demo COMMAND [arguments]\n" + " bound | templates | languages\n" + " custom PAGE SIZE\n" + " timbres LANG PAGE SIZE\n" + " bind template|custom ROLE_ID\n" + " edit template|custom ROLE_ID name=NAME lang=CODE timbre=ID speed=1.20\n" + " (supply only the fields to change)\n" + " delete custom ROLE_ID --confirm\n" + "Credentials come from TUYA_DEVICE_ID, TUYA_SECRET_KEY, TUYA_LOCAL_KEY;\n" + "TUYA_CA_FILE is required; TUYA_REGION defaults to AY. Secrets are not printed."); +} + +static int positive_integer(const char *s) +{ + char *end; errno = 0; + long n = strtol(s, &end, 10); + return errno || end == s || *end || n < 1 || n > INT_MAX ? 0 : (int)n; +} +static int role_type(const char *s) +{ + return !strcmp(s, "template") ? TUYA_AI_ROLE_TEMPLATE : + !strcmp(s, "custom") ? TUYA_AI_ROLE_CUSTOM : -1; +} +static int current(tuya_biz_client_t *biz, iot_atop_rejection_t *err) +{ + tuya_ai_role_t role; + int rc = tuya_ai_role_get_bound(biz, &role, err); + if (!rc) printf("bound: id=%s type=%d name=%s lang=%s timbre=%s speed=%.2f\n", + role.role_id, role.type, role.name, role.lang_code, role.timbre_id, role.speed); + return rc; +} + +int role_demo_execute(tuya_biz_client_t *biz, int argc, const char *const argv[]) +{ + if (!biz || argc < 1) return OPRT_INVALID_PARAMETER; + iot_atop_rejection_t err = {0}; + int rc = OPRT_INVALID_PARAMETER; + bool changed = false; + if (!strcmp(argv[0], "bound") && argc == 1) rc = current(biz, &err); + else if ((!strcmp(argv[0], "templates") && argc == 1) || + (!strcmp(argv[0], "custom") && argc == 3)) { + tuya_ai_role_list_t list = {0}; + rc = argc == 1 ? tuya_ai_role_list_templates(biz, &list, &err) : + tuya_ai_role_list_custom(biz, positive_integer(argv[1]), positive_integer(argv[2]), &list, &err); + if (!rc) { + printf("roles: %zu, total=%d\n", list.count, list.total_count); + for (size_t i = 0; i < list.count; ++i) + printf(" %s type=%d %s\n", list.items[i].role_id, list.items[i].type, list.items[i].name); + } + tuya_ai_role_list_free(biz, &list); + } else if (!strcmp(argv[0], "languages") && argc == 1) { + tuya_ai_language_list_t list = {0}; + rc = tuya_ai_role_list_languages(biz, &list, &err); + if (!rc) for (size_t i = 0; i < list.count; ++i) + printf("%s %s\n", list.items[i].code, list.items[i].name); + tuya_ai_language_list_free(biz, &list); + } else if (!strcmp(argv[0], "timbres") && argc == 4) { + tuya_ai_timbre_list_t list = {0}; + rc = tuya_ai_role_list_timbres(biz, argv[1], positive_integer(argv[2]), + positive_integer(argv[3]), &list, &err); + if (!rc) { + printf("timbres: %zu, total=%d\n", list.count, list.total_count); + for (size_t i = 0; i < list.count; ++i) + printf("%s %s [%s]\n", list.items[i].id, list.items[i].name, list.items[i].support_langs); + } + tuya_ai_timbre_list_free(biz, &list); + } else if (!strcmp(argv[0], "bind") && argc == 3 && role_type(argv[1]) >= 0) { + rc = tuya_ai_role_bind(biz, (tuya_ai_role_type_t)role_type(argv[1]), argv[2], &err); + changed = !rc; + } else if (!strcmp(argv[0], "edit") && argc >= 4 && role_type(argv[1]) >= 0) { + tuya_ai_role_t role = { .type = (tuya_ai_role_type_t)role_type(argv[1]) }; + tuya_ai_role_edit_t edit = {0}; + if (strlen(argv[2]) >= sizeof(role.role_id)) goto done; + strcpy(role.role_id, argv[2]); + for (int i = 3; i < argc; ++i) { + if (!strncmp(argv[i], "name=", 5)) edit.name = argv[i] + 5; + else if (!strncmp(argv[i], "lang=", 5)) edit.lang_code = argv[i] + 5; + else if (!strncmp(argv[i], "timbre=", 7)) edit.timbre_id = argv[i] + 7; + else if (!strncmp(argv[i], "speed=", 6)) { + char *end; errno = 0; + edit.speed = strtod(argv[i] + 6, &end); + if (errno || end == argv[i] + 6 || *end) goto done; + edit.set_speed = true; + } else goto done; + } + char id[TUYA_AI_ROLE_ID_LEN]; + rc = tuya_ai_role_apply_edit(biz, &role, &edit, id, &err); + if (!rc) printf("saved role: %s\n", id); + changed = !rc; + } else if (!strcmp(argv[0], "delete") && argc == 4 && + !strcmp(argv[1], "custom") && !strcmp(argv[3], "--confirm")) { + tuya_ai_role_t bound; + rc = tuya_ai_role_get_bound(biz, &bound, &err); + if (!rc && !strcmp(bound.role_id, argv[2])) { + fprintf(stderr, "Select another role before deleting the current role.\n"); + rc = OPRT_INVALID_PARAMETER; + } else if (!rc) { + rc = tuya_ai_role_delete_custom(biz, argv[2], &err); + changed = !rc; + } + } + if (changed) { + puts("Cloud change accepted; refreshing bound role (not a voice-effect check)."); + rc = current(biz, &err); + if (rc) fprintf(stderr, "Change accepted, refresh failed; query before retrying a write.\n"); + } +done: + if (rc) fprintf(stderr, "role operation failed: rc=%d cloud=%s %s\n", rc, err.code, err.msg); + return rc; +} diff --git a/examples/posix/ai/role-management/role_demo.h b/examples/posix/ai/role-management/role_demo.h new file mode 100644 index 0000000..8b018bf --- /dev/null +++ b/examples/posix/ai/role-management/role_demo.h @@ -0,0 +1,7 @@ +#ifndef ROLE_DEMO_H +#define ROLE_DEMO_H +#include "tuya_ai_role.h" +/* Synchronous command executor; call only in an application worker. */ +int role_demo_execute(tuya_biz_client_t *biz, int argc, const char *const argv[]); +void role_demo_usage(void); +#endif diff --git a/examples/posix/ai/role-management/role_demo_main.c b/examples/posix/ai/role-management/role_demo_main.c new file mode 100644 index 0000000..b1ae93b --- /dev/null +++ b/examples/posix/ai/role-management/role_demo_main.c @@ -0,0 +1,67 @@ +#include "role_demo.h" +#include +#include +#include + +static char *load_ca(const char *path) +{ + FILE *f = fopen(path, "rb"); + if (!f) return NULL; + char *pem = NULL; + if (!fseek(f, 0, SEEK_END)) { + long n = ftell(f); + if (n > 0 && n <= 128 * 1024 && !fseek(f, 0, SEEK_SET)) { + pem = malloc((size_t)n + 1); + if (pem && fread(pem, 1, (size_t)n, f) == (size_t)n) pem[n] = 0; + else { free(pem); pem = NULL; } + } + } + fclose(f); + return pem; +} +static bool credential(char *out, size_t size, const char *key) +{ + const char *s = getenv(key); + if (!s || !s[0] || strlen(s) >= size) return false; + strcpy(out, s); + return true; +} +static bool region(const char *name, iot_region_t *out) +{ + const char *names[] = {"AY", "AZ", "UE", "EU", "WE", "IN", "SG"}; + const iot_region_t values[] = {AY, AZ, UEAZ, EU, WEAZ, IN, SG}; + if (!name) name = "AY"; + for (size_t i = 0; i < sizeof(values)/sizeof(values[0]); ++i) + if (!strcmp(name, names[i])) { *out = values[i]; return true; } + return false; +} + +int main(int argc, char **argv) +{ + if (argc < 2 || !strcmp(argv[1], "--help")) { role_demo_usage(); return argc < 2 ? 1 : 0; } + /* This tool operates an existing device identity; it must not overwrite + * that device's real firmware/SDK version metadata while merely querying. */ + iot_client_config_t cfg = { .env = PROD, .mqtt_disable_auto_connect = true, + .skip_version_report = true }; + const char *ca_file = getenv("TUYA_CA_FILE"); + if (!credential(cfg.devid, sizeof(cfg.devid), "TUYA_DEVICE_ID") || + !credential(cfg.secret_key, sizeof(cfg.secret_key), "TUYA_SECRET_KEY") || + !credential(cfg.local_key, sizeof(cfg.local_key), "TUYA_LOCAL_KEY") || + !ca_file || !region(getenv("TUYA_REGION"), &cfg.region)) { + fprintf(stderr, "Provide valid device credentials, TUYA_REGION and TUYA_CA_FILE in the environment.\n"); + return 1; + } + char *ca = load_ca(ca_file); + if (!ca) { fprintf(stderr, "Cannot read TUYA_CA_FILE.\n"); return 1; } + cfg.cacert = ca; + int rc = iot_init_default(); + iot_client_t *iot = rc ? NULL : iot_client_init(&cfg); + if (!iot) { free(ca); return 1; } + tuya_biz_client_t *biz = NULL; + rc = tuya_biz_client_create(iot, "zh", &biz); + if (!rc) rc = role_demo_execute(biz, argc - 1, (const char *const *)(argv + 1)); + tuya_biz_client_destroy(biz); + iot_client_deinit(iot); + free(ca); + return rc ? 1 : 0; +} diff --git a/modules/biz-client/CONTEXT.md b/modules/biz-client/CONTEXT.md new file mode 100644 index 0000000..8c7e757 --- /dev/null +++ b/modules/biz-client/CONTEXT.md @@ -0,0 +1,40 @@ +# Business Client + +Typed device-side cloud services over the IoT Client's public ATOP generic call. +The CMake target is `tuya_biz_client`; the public headers are +`tuya_biz_client.h` and `tuya_ai_role.h`. This module has no platform UI, +storage, background tasks, MQTT loop, or RTC Session ownership. + +## Language + +- **Business Client**: an instance borrowing one activated IoT Client and its + PAL. Owns the display request language, not device credentials. +- **Role template**: cloud-provided read-only role. Editing creates a custom + role with `needBind=true`; the template itself is never overwritten. +- **Custom role**: an editable role. Updating it does not change the device's + current role binding. Only custom roles may be submitted for deletion. +- **Role binding**: the device's cloud-selected role, queried independently + from its available role lists. It is not the user-device binding. +- **Reply language**: `useLangCode`. Keep its full cloud code, including any + dialect suffix. It is distinct from `requestLang`, the label language. +- **Timbre**: a selectable TTS voice (`voiceId` from the market list, + `useTimbreId` when editing a role). +- **Named wrapper** and **Generic call** retain the IoT Client's meanings. + +## Contracts + +- Public APIs are synchronous. The app owns scheduling and serializes use + with client teardown. No request is automatically retried; template creation + may have succeeded on a timeout, so query before resubmitting. +- Memory comes from the borrowed IoT PAL. cJSON uses the SDK-wide allocator + installed by `iot_init`; never install a second set of hooks here. +- Output lists must be freed with the same live Business Client. Query outputs + must be empty on entry; failures leave no partially parsed output. +- Preserve `OPRT_ATOP_BUSINESS_ERROR` and the cloud rejection code/message. + An accepted envelope is not enough: each result must match the operation's + shape. Empty lists are valid; absent/null results and false acknowledgements + are invalid. IDs and strings are never silently truncated. +- No RTC TCP Client dependency. The app verifies when a new role affects its + next Event and decides if a new Session is required. + +Decision: [ATOP business services remain separate](docs/adr/0001-business-services-use-public-atop.md). diff --git a/modules/biz-client/docs/adr/0001-business-services-use-public-atop.md b/modules/biz-client/docs/adr/0001-business-services-use-public-atop.md new file mode 100644 index 0000000..2847903 --- /dev/null +++ b/modules/biz-client/docs/adr/0001-business-services-use-public-atop.md @@ -0,0 +1,23 @@ +# Typed business services use the public ATOP API + +Status: accepted. + +Role selection is needed by multiple products. It has business semantics beyond +HTTP: editing templates creates and binds custom roles, edits omit unchanged +fields, language/timbre lists use full language codes, and results have several +known list envelopes. This knowledge belongs in shared named wrappers. + +Place these wrappers in `modules/biz-client`, above `iot_atop_call()`. Keep IoT +activation, credentials and ATOP transport in iot-client, and Session/Event and +media transport in rtc-tcp-client. A Business Client borrows an IoT Client and +its PAL; callers never supply cloud secrets again. + +Use typed C results, explicit pagination and matching release functions. Keep +cloud rejection details, validate results, and reject truncation. Use PAL +allocation and the common log facade so POSIX and FreeRTOS share implementation. +Add sources to both root CMake and the ESP-IDF component. + +NVS caching, workers, queue ownership, avatar HTTP fetching/decoding, LVGL and +TAI reconnection remain application concerns. Copying the study-machine role +component unchanged would import ESP-IDF/PSRAM/NVS and a global client into a +portable SDK, so only its cloud API semantics are carried over. diff --git a/modules/biz-client/include/tuya_ai_role.h b/modules/biz-client/include/tuya_ai_role.h new file mode 100644 index 0000000..f01e2be --- /dev/null +++ b/modules/biz-client/include/tuya_ai_role.h @@ -0,0 +1,113 @@ +#ifndef TUYA_AI_ROLE_H +#define TUYA_AI_ROLE_H + +#include "tuya_biz_client.h" + +#ifdef __cplusplus +extern "C" { +#endif + +#define TUYA_AI_ROLE_ID_LEN 64 +#define TUYA_AI_ROLE_NAME_LEN 128 +#define TUYA_AI_ROLE_DESC_LEN 512 +#define TUYA_AI_ROLE_URL_LEN 1024 +#define TUYA_AI_ROLE_LANG_LEN 32 + +typedef enum { + TUYA_AI_ROLE_CUSTOM = 0, + TUYA_AI_ROLE_TEMPLATE = 1, + TUYA_AI_ROLE_SINGLE = 2 +} tuya_ai_role_type_t; + +typedef struct { + char role_id[TUYA_AI_ROLE_ID_LEN]; + char template_id[TUYA_AI_ROLE_ID_LEN]; + tuya_ai_role_type_t type; + char name[TUYA_AI_ROLE_NAME_LEN]; + char description[TUYA_AI_ROLE_DESC_LEN]; + char image_url[TUYA_AI_ROLE_URL_LEN]; + char lang_code[TUYA_AI_ROLE_LANG_LEN]; + char lang_name[TUYA_AI_ROLE_NAME_LEN]; + char timbre_id[TUYA_AI_ROLE_ID_LEN]; + char timbre_name[TUYA_AI_ROLE_NAME_LEN]; + double speed; +} tuya_ai_role_t; + +typedef struct { + tuya_ai_role_t *items; + size_t count; + int total_count; /* -1 if cloud omitted pagination metadata */ +} tuya_ai_role_list_t; + +typedef struct { + char code[TUYA_AI_ROLE_LANG_LEN]; + char name[TUYA_AI_ROLE_NAME_LEN]; + bool has_default; +} tuya_ai_language_t; + +typedef struct { + tuya_ai_language_t *items; + size_t count; +} tuya_ai_language_list_t; + +typedef struct { + char id[TUYA_AI_ROLE_ID_LEN]; + char name[TUYA_AI_ROLE_NAME_LEN]; + /* Full language codes, comma-separated. An overlong value is rejected. */ + char support_langs[512]; +} tuya_ai_timbre_t; + +typedef struct { + tuya_ai_timbre_t *items; + size_t count; + int total_count; +} tuya_ai_timbre_list_t; + +/** NULL means unchanged; set_speed=false preserves speed. Strings are + * borrowed only until this synchronous call returns. No prompt editing. */ +typedef struct { + const char *name; + const char *lang_code; + const char *timbre_id; + bool set_speed; + double speed; /* finite and positive; cloud decides supported range */ +} tuya_ai_role_edit_t; + +/** All operations return OPRT_* and clear their optional rejection on entry. + * OPRT_ATOP_BUSINESS_ERROR preserves the cloud's code/msg in rejection. + * Query outputs are empty on failure. Release lists with the matching free + * function and SAME live context before replacing/reusing the output struct. + * No cJSON object or SDK-private header crosses this public boundary. + * Strings that exceed the public capacities are rejected, never truncated. */ +int tuya_ai_role_get_bound(tuya_biz_client_t *, tuya_ai_role_t *, iot_atop_rejection_t *); +int tuya_ai_role_list_templates(tuya_biz_client_t *, tuya_ai_role_list_t *, iot_atop_rejection_t *); +int tuya_ai_role_list_custom(tuya_biz_client_t *, int page_no, int page_size, + tuya_ai_role_list_t *, iot_atop_rejection_t *); +int tuya_ai_role_bind(tuya_biz_client_t *, tuya_ai_role_type_t, const char *role_id, + iot_atop_rejection_t *); +/** Explicit deletion is custom-only. Binding/fallback policy stays in the app. */ +int tuya_ai_role_delete_custom(tuya_biz_client_t *, const char *role_id, + iot_atop_rejection_t *); +/** Template edit creates a custom role with needBind=true; custom edit updates + * it in place without rebinding. SINGLE and empty edits are rejected. + * out_id must have TUYA_AI_ROLE_ID_LEN bytes; zeroed on failure. No auto-retry: + * a timeout may mean creation succeeded. Refresh before submitting again. */ +int tuya_ai_role_apply_edit(tuya_biz_client_t *, const tuya_ai_role_t *, + const tuya_ai_role_edit_t *, + char out_id[TUYA_AI_ROLE_ID_LEN], iot_atop_rejection_t *); +int tuya_ai_role_list_languages(tuya_biz_client_t *, tuya_ai_language_list_t *, + iot_atop_rejection_t *); +/** Full lang code is sent unchanged (e.g. zh_ha). NULL means unfiltered. + * Pagination is explicit; no hidden first-page-only limit or automatic retry. */ +int tuya_ai_role_list_timbres(tuya_biz_client_t *, const char *lang, + int page_no, int page_size, tuya_ai_timbre_list_t *, + iot_atop_rejection_t *); + +void tuya_ai_role_list_free(tuya_biz_client_t *, tuya_ai_role_list_t *); +void tuya_ai_language_list_free(tuya_biz_client_t *, tuya_ai_language_list_t *); +void tuya_ai_timbre_list_free(tuya_biz_client_t *, tuya_ai_timbre_list_t *); + +#ifdef __cplusplus +} +#endif +#endif diff --git a/modules/biz-client/include/tuya_biz_client.h b/modules/biz-client/include/tuya_biz_client.h new file mode 100644 index 0000000..d1726f2 --- /dev/null +++ b/modules/biz-client/include/tuya_biz_client.h @@ -0,0 +1,28 @@ +#ifndef TUYA_BIZ_CLIENT_H +#define TUYA_BIZ_CLIENT_H + +#include "iot_client.h" + +#ifdef __cplusplus +extern "C" { +#endif + +/** Typed device-side cloud services, over the public IoT ATOP API. + * Call iot_init()/iot_init_default() first. The IoT client and its PAL are + * borrowed and must outlive this context and every result allocated by it. + * Calls are synchronous; serialize calls and destruction in an app-owned + * worker, never in a UI/MQTT/TAI receive callback. No task, MQTT connection, + * cache, retry, or TAI Session is created or managed here. */ +typedef struct tuya_biz_client tuya_biz_client_t; + +/** request_lang controls display labels, NOT the role's reply language. + * NULL defaults to "zh"; nonempty UTF-8 strings of at most 31 bytes accepted. + * The client must be activated before performing cloud operations. */ +int tuya_biz_client_create(iot_client_t *iot, const char *request_lang, + tuya_biz_client_t **out); +void tuya_biz_client_destroy(tuya_biz_client_t *client); + +#ifdef __cplusplus +} +#endif +#endif diff --git a/modules/biz-client/src/ai_role.c b/modules/biz-client/src/ai_role.c new file mode 100644 index 0000000..1af2450 --- /dev/null +++ b/modules/biz-client/src/ai_role.c @@ -0,0 +1,331 @@ +#include "tuya_ai_role.h" +#include "service_internal.h" +#include +#include +#include +#include + +#define ROLE_API "thing.ai.agent.role." + +static const cJSON *list_array(const cJSON *root) +{ + if (cJSON_IsArray(root)) return root; + if (!cJSON_IsObject(root)) return NULL; + const char *keys[] = {"datas", "list", "records", "rows", "content", "data", "items"}; + for (size_t i = 0; i < sizeof(keys)/sizeof(keys[0]); ++i) { + const cJSON *v = cJSON_GetObjectItemCaseSensitive(root, keys[i]); + if (cJSON_IsArray(v)) return v; + } + return NULL; +} + +static int total_count(const cJSON *root, int *out) +{ + *out = -1; + if (!cJSON_IsObject(root)) return OPRT_OK; + const cJSON *v = cJSON_GetObjectItemCaseSensitive(root, "totalCount"); + if (!v) return OPRT_OK; + if (!cJSON_IsNumber(v) || !isfinite(v->valuedouble) || v->valuedouble < 0 || + v->valuedouble > INT_MAX || floor(v->valuedouble) != v->valuedouble) return OPRT_INVALID_RESULT; + *out = v->valueint; + return OPRT_OK; +} + +static int parse_role(const cJSON *obj, tuya_ai_role_t *out, int source_type) +{ + if (!cJSON_IsObject(obj)) return OPRT_INVALID_RESULT; + memset(out, 0, sizeof(*out)); + out->speed = 1.0; + out->type = TUYA_AI_ROLE_SINGLE; +#define FIELD(member, key, required) \ + do { if (biz_copy_string(obj, key, out->member, sizeof(out->member), required)) return OPRT_INVALID_RESULT; } while (0) + FIELD(role_id, "roleId", true); + FIELD(template_id, "templateId", false); + FIELD(name, "roleName", false); + FIELD(description, "roleDesc", false); + FIELD(image_url, "roleImgUrl", false); + FIELD(lang_code, "useLangCode", false); + FIELD(lang_name, "useLangName", false); + FIELD(timbre_id, "useTimbreId", false); + FIELD(timbre_name, "useTimbreName", false); +#undef FIELD + const cJSON *type = cJSON_GetObjectItemCaseSensitive(obj, "bindRoleType"); + if (source_type >= 0) out->type = (tuya_ai_role_type_t)source_type; + else if (type) { + if (!cJSON_IsNumber(type) || type->valuedouble < 0 || type->valuedouble > 2 || + type->valuedouble != type->valueint) return OPRT_INVALID_RESULT; + out->type = (tuya_ai_role_type_t)type->valueint; + } + const cJSON *speed = cJSON_GetObjectItemCaseSensitive(obj, "speed"); + if (speed) { + if (!cJSON_IsNumber(speed) || !isfinite(speed->valuedouble) || speed->valuedouble <= 0) + return OPRT_INVALID_RESULT; + out->speed = speed->valuedouble; + } + return OPRT_OK; +} + +void tuya_ai_role_list_free(tuya_biz_client_t *ctx, tuya_ai_role_list_t *out) +{ + if (ctx && out) { ctx->pal->free(out->items); memset(out, 0, sizeof(*out)); } +} +void tuya_ai_language_list_free(tuya_biz_client_t *ctx, tuya_ai_language_list_t *out) +{ + if (ctx && out) { ctx->pal->free(out->items); memset(out, 0, sizeof(*out)); } +} +void tuya_ai_timbre_list_free(tuya_biz_client_t *ctx, tuya_ai_timbre_list_t *out) +{ + if (ctx && out) { ctx->pal->free(out->items); memset(out, 0, sizeof(*out)); } +} + +int tuya_ai_role_get_bound(tuya_biz_client_t *ctx, tuya_ai_role_t *out, iot_atop_rejection_t *err) +{ + int rc = biz_prepare(ctx, err); + if (out) memset(out, 0, sizeof(*out)); + if (rc || !out) return OPRT_INVALID_PARAMETER; + cJSON *root = NULL; + rc = biz_call(ctx, ROLE_API "get-bind-role", biz_query_body(ctx), &root, err); + if (!rc) { + rc = parse_role(root, out, -1); + if (rc) memset(out, 0, sizeof(*out)); + } + cJSON_Delete(root); + return rc; +} + +static cJSON *paged_body(tuya_biz_client_t *ctx, int page, int size) +{ + cJSON *body = biz_query_body(ctx); + if (!body || !cJSON_AddNumberToObject(body, "pageNo", page) || + !cJSON_AddNumberToObject(body, "pageSize", size)) { + cJSON_Delete(body); + return NULL; + } + return body; +} + +static int roles_query(tuya_biz_client_t *ctx, const char *api, cJSON *body, + int type, tuya_ai_role_list_t *out, iot_atop_rejection_t *err) +{ + cJSON *root = NULL; + int rc = biz_call(ctx, api, body, &root, err); + if (rc) return rc; + const cJSON *array = list_array(root); + size_t n = array ? (size_t)cJSON_GetArraySize(array) : 0; + if (!array || total_count(root, &out->total_count)) rc = OPRT_INVALID_RESULT; + else if (n) { + if (n > SIZE_MAX / sizeof(*out->items)) rc = OPRT_MALLOC_FAILED; + else out->items = ctx->pal->malloc(n * sizeof(*out->items)); + if (!rc && !out->items) rc = OPRT_MALLOC_FAILED; + if (!rc) { + const cJSON *entry = NULL; + cJSON_ArrayForEach(entry, array) { + rc = parse_role(entry, &out->items[out->count], type); + if (rc) break; + ++out->count; + } + } + } + cJSON_Delete(root); + if (rc) tuya_ai_role_list_free(ctx, out); + return rc; +} + +int tuya_ai_role_list_templates(tuya_biz_client_t *ctx, tuya_ai_role_list_t *out, iot_atop_rejection_t *err) +{ + int rc = biz_prepare(ctx, err); + if (out) memset(out, 0, sizeof(*out)); + if (rc || !out) return OPRT_INVALID_PARAMETER; + return roles_query(ctx, ROLE_API "role-template.list", biz_query_body(ctx), + TUYA_AI_ROLE_TEMPLATE, out, err); +} + +int tuya_ai_role_list_custom(tuya_biz_client_t *ctx, int page, int size, + tuya_ai_role_list_t *out, iot_atop_rejection_t *err) +{ + int rc = biz_prepare(ctx, err); + if (out) memset(out, 0, sizeof(*out)); + if (rc || !out || page < 1 || size < 1) return OPRT_INVALID_PARAMETER; + return roles_query(ctx, ROLE_API "custom-role.page", paged_body(ctx, page, size), + TUYA_AI_ROLE_CUSTOM, out, err); +} + +static int mutate(tuya_biz_client_t *ctx, const char *api, cJSON *body, iot_atop_rejection_t *err) +{ + cJSON *root = NULL; + int rc = biz_call(ctx, api, body, &root, err); + cJSON_Delete(root); + return rc; +} + +int tuya_ai_role_bind(tuya_biz_client_t *ctx, tuya_ai_role_type_t type, + const char *id, iot_atop_rejection_t *err) +{ + int rc = biz_prepare(ctx, err); + if (rc || (type != TUYA_AI_ROLE_CUSTOM && type != TUYA_AI_ROLE_TEMPLATE) || + !biz_valid_string(id, TUYA_AI_ROLE_ID_LEN)) return OPRT_INVALID_PARAMETER; + cJSON *body = cJSON_CreateObject(); + if (!body || !cJSON_AddStringToObject(body, "roleId", id) || + !cJSON_AddNumberToObject(body, "bindRoleType", type)) { + cJSON_Delete(body); return OPRT_MALLOC_FAILED; + } + return mutate(ctx, ROLE_API "bind-with-role", body, err); +} + +int tuya_ai_role_delete_custom(tuya_biz_client_t *ctx, const char *id, iot_atop_rejection_t *err) +{ + int rc = biz_prepare(ctx, err); + if (rc || !biz_valid_string(id, TUYA_AI_ROLE_ID_LEN)) return OPRT_INVALID_PARAMETER; + cJSON *body = cJSON_CreateObject(); + if (!body || !cJSON_AddStringToObject(body, "roleId", id)) { + cJSON_Delete(body); return OPRT_MALLOC_FAILED; + } + return mutate(ctx, ROLE_API "custom-role.delete", body, err); +} + +static bool valid_edit(const tuya_ai_role_edit_t *edit) +{ + return edit && (edit->name || edit->lang_code || edit->timbre_id || edit->set_speed) && + (!edit->name || biz_valid_string(edit->name, TUYA_AI_ROLE_NAME_LEN)) && + (!edit->lang_code || biz_valid_string(edit->lang_code, TUYA_AI_ROLE_LANG_LEN)) && + (!edit->timbre_id || biz_valid_string(edit->timbre_id, TUYA_AI_ROLE_ID_LEN)) && + (!edit->set_speed || (isfinite(edit->speed) && edit->speed > 0)); +} + +static cJSON *edit_body(const char *id, const tuya_ai_role_edit_t *edit, bool create) +{ + cJSON *body = cJSON_CreateObject(); + if (!body || !cJSON_AddStringToObject(body, "roleId", id)) goto fail; + if (edit->name && !cJSON_AddStringToObject(body, "roleName", edit->name)) goto fail; + if (edit->lang_code && !cJSON_AddStringToObject(body, "useLangCode", edit->lang_code)) goto fail; + if (edit->timbre_id && !cJSON_AddStringToObject(body, "useTimbreId", edit->timbre_id)) goto fail; + if (edit->set_speed) { + char speed[64]; + int n = snprintf(speed, sizeof(speed), "%.2f", edit->speed); + if (n < 0 || (size_t)n >= sizeof(speed) || !cJSON_AddStringToObject(body, "speed", speed)) goto fail; + } + if (create && !cJSON_AddBoolToObject(body, "needBind", true)) goto fail; + return body; +fail: + cJSON_Delete(body); + return NULL; +} + +int tuya_ai_role_apply_edit(tuya_biz_client_t *ctx, const tuya_ai_role_t *cur, + const tuya_ai_role_edit_t *edit, char out_id[TUYA_AI_ROLE_ID_LEN], + iot_atop_rejection_t *err) +{ + int rc = biz_prepare(ctx, err); + if (out_id) out_id[0] = '\0'; + if (rc || !out_id || !cur || !biz_valid_string(cur->role_id, sizeof(cur->role_id)) || + (cur->type != TUYA_AI_ROLE_TEMPLATE && cur->type != TUYA_AI_ROLE_CUSTOM) || + !valid_edit(edit)) return OPRT_INVALID_PARAMETER; + bool create = cur->type == TUYA_AI_ROLE_TEMPLATE; + cJSON *root = NULL; + rc = biz_call(ctx, create ? ROLE_API "custom-role.create-from-template" : ROLE_API "custom-role.update", + edit_body(cur->role_id, edit, create), &root, err); + if (!rc) { + if (create) { + if (!cJSON_IsString(root) || !biz_valid_string(root->valuestring, TUYA_AI_ROLE_ID_LEN)) + rc = OPRT_INVALID_RESULT; + else strcpy(out_id, root->valuestring); + } else strcpy(out_id, cur->role_id); + } + cJSON_Delete(root); + return rc; +} + +int tuya_ai_role_list_languages(tuya_biz_client_t *ctx, tuya_ai_language_list_t *out, + iot_atop_rejection_t *err) +{ + int rc = biz_prepare(ctx, err); + if (out) memset(out, 0, sizeof(*out)); + if (rc || !out) return OPRT_INVALID_PARAMETER; + cJSON *root = NULL; + rc = biz_call(ctx, "thing.ai.agent.config.languages.list", biz_query_body(ctx), &root, err); + if (rc) return rc; + const cJSON *array = list_array(root); + size_t n = array ? (size_t)cJSON_GetArraySize(array) : 0; + if (!array) rc = OPRT_INVALID_RESULT; + else if (n) { + if (n > SIZE_MAX / sizeof(*out->items)) rc = OPRT_MALLOC_FAILED; + else out->items = ctx->pal->malloc(n * sizeof(*out->items)); + if (!rc && !out->items) rc = OPRT_MALLOC_FAILED; + if (!rc) { + const cJSON *entry = NULL; + cJSON_ArrayForEach(entry, array) { + tuya_ai_language_t *item = &out->items[out->count]; + memset(item, 0, sizeof(*item)); + if (!cJSON_IsObject(entry) || + biz_copy_string(entry, "langCode", item->code, sizeof(item->code), true) || + biz_copy_string(entry, "langName", item->name, sizeof(item->name), false)) { + rc = OPRT_INVALID_RESULT; break; + } + const cJSON *def = cJSON_GetObjectItemCaseSensitive(entry, "hasDefault"); + if (def && !cJSON_IsBool(def)) { rc = OPRT_INVALID_RESULT; break; } + item->has_default = cJSON_IsTrue(def); + ++out->count; + } + } + } + cJSON_Delete(root); + if (rc) tuya_ai_language_list_free(ctx, out); + return rc; +} + +static int parse_timbre(const cJSON *obj, tuya_ai_timbre_t *out) +{ + memset(out, 0, sizeof(*out)); + if (!cJSON_IsObject(obj) || biz_copy_string(obj, "voiceId", out->id, sizeof(out->id), true) || + biz_copy_string(obj, "voiceName", out->name, sizeof(out->name), false)) return OPRT_INVALID_RESULT; + const cJSON *langs = cJSON_GetObjectItemCaseSensitive(obj, "supportLangs"); + if (!langs || cJSON_IsNull(langs)) return OPRT_OK; + if (!cJSON_IsArray(langs)) return OPRT_INVALID_RESULT; + const cJSON *v = NULL; + size_t used = 0; + cJSON_ArrayForEach(v, langs) { + if (!cJSON_IsString(v) || !v->valuestring[0]) return OPRT_INVALID_RESULT; + size_t len = strlen(v->valuestring); + size_t sep = used ? 1 : 0; + if (len >= sizeof(out->support_langs) - used - sep) return OPRT_INVALID_RESULT; + if (sep) out->support_langs[used++] = ','; + memcpy(out->support_langs + used, v->valuestring, len + 1); + used += len; + } + return OPRT_OK; +} + +int tuya_ai_role_list_timbres(tuya_biz_client_t *ctx, const char *lang, int page, int size, + tuya_ai_timbre_list_t *out, iot_atop_rejection_t *err) +{ + int rc = biz_prepare(ctx, err); + if (out) memset(out, 0, sizeof(*out)); + if (rc || !out || page < 1 || size < 1 || + (lang && !biz_valid_string(lang, TUYA_AI_ROLE_LANG_LEN))) return OPRT_INVALID_PARAMETER; + cJSON *body = paged_body(ctx, page, size); + if (body && lang && !cJSON_AddStringToObject(body, "lang", lang)) { + cJSON_Delete(body); return OPRT_MALLOC_FAILED; + } + cJSON *root = NULL; + rc = biz_call(ctx, "thing.ai.timbre.market.page", body, &root, err); + if (rc) return rc; + const cJSON *array = list_array(root); + size_t n = array ? (size_t)cJSON_GetArraySize(array) : 0; + if (!array || total_count(root, &out->total_count)) rc = OPRT_INVALID_RESULT; + else if (n) { + if (n > SIZE_MAX / sizeof(*out->items)) rc = OPRT_MALLOC_FAILED; + else out->items = ctx->pal->malloc(n * sizeof(*out->items)); + if (!rc && !out->items) rc = OPRT_MALLOC_FAILED; + if (!rc) { + const cJSON *entry = NULL; + cJSON_ArrayForEach(entry, array) { + rc = parse_timbre(entry, &out->items[out->count]); + if (rc) break; + ++out->count; + } + } + } + cJSON_Delete(root); + if (rc) tuya_ai_timbre_list_free(ctx, out); + return rc; +} diff --git a/modules/biz-client/src/biz_client.c b/modules/biz-client/src/biz_client.c new file mode 100644 index 0000000..ec78ce0 --- /dev/null +++ b/modules/biz-client/src/biz_client.c @@ -0,0 +1,101 @@ +#include "service_internal.h" +#include "log.h" +#include +#include + +bool biz_valid_string(const char *text, size_t capacity) +{ + if (!text || !text[0]) return false; + /* Bounded even for an unterminated public fixed-size field. */ + for (size_t i = 1; i < capacity; ++i) if (!text[i]) return true; + return false; +} + +int tuya_biz_client_create(iot_client_t *iot, const char *lang, tuya_biz_client_t **out) +{ + if (!out) return OPRT_INVALID_PARAMETER; + *out = NULL; + if (!iot || !iot->pal || !iot->pal->malloc || !iot->pal->free || + (lang && !biz_valid_string(lang, 32))) return OPRT_INVALID_PARAMETER; + tuya_biz_client_t *ctx = iot->pal->malloc(sizeof(*ctx)); + if (!ctx) return OPRT_MALLOC_FAILED; + memset(ctx, 0, sizeof(*ctx)); + ctx->iot = iot; + ctx->pal = iot->pal; + strcpy(ctx->request_lang, lang ? lang : "zh"); + *out = ctx; + return OPRT_OK; +} + +void tuya_biz_client_destroy(tuya_biz_client_t *ctx) +{ + if (ctx) ctx->pal->free(ctx); +} + +int biz_prepare(tuya_biz_client_t *ctx, iot_atop_rejection_t *rejection) +{ + if (rejection) memset(rejection, 0, sizeof(*rejection)); + return ctx && ctx->iot && ctx->pal ? OPRT_OK : OPRT_INVALID_PARAMETER; +} + +cJSON *biz_query_body(const tuya_biz_client_t *ctx) +{ + cJSON *body = cJSON_CreateObject(); + if (!body || !cJSON_AddStringToObject(body, "requestLang", ctx->request_lang)) { + cJSON_Delete(body); + return NULL; + } + return body; +} + +int biz_call(tuya_biz_client_t *ctx, const char *api, cJSON *body, + cJSON **result, iot_atop_rejection_t *rejection) +{ + *result = NULL; + if (!body) return OPRT_MALLOC_FAILED; + time_t now = time(NULL); + if (now < 1700000000) { + log_tag_warn("biz", "clock is not synchronized; refusing signed role request"); + cJSON_Delete(body); + return OPRT_UNINITIALIZED; + } + if (!cJSON_AddNumberToObject(body, "t", (double)now)) { + cJSON_Delete(body); + return OPRT_MALLOC_FAILED; + } + char *text = cJSON_PrintUnformatted(body); + cJSON_Delete(body); + if (!text) return OPRT_MALLOC_FAILED; + iot_atop_request_t request = { .api = api, .version = "1.0", .data = text }; + iot_atop_response_t response = {0}; + int rc = iot_atop_call(ctx->iot, &request, &response); + cJSON_free(text); + if (rc == OPRT_ATOP_BUSINESS_ERROR && rejection) { + memcpy(rejection->code, response.error_code, sizeof(rejection->code)); + memcpy(rejection->msg, response.error_msg, sizeof(rejection->msg)); + rejection->code[sizeof(rejection->code) - 1] = '\0'; + rejection->msg[sizeof(rejection->msg) - 1] = '\0'; + } + if (rc == OPRT_OK) { + if (response.result) *result = cJSON_ParseWithOpts(response.result, NULL, 1); + if (!*result || cJSON_IsNull(*result) || cJSON_IsFalse(*result)) { + cJSON_Delete(*result); + *result = NULL; + rc = OPRT_INVALID_RESULT; + } + } + iot_atop_response_free(ctx->iot, &response); + if (rc != OPRT_OK) log_tag_warn("biz", "%s failed: %d", api, rc); + return rc; +} + +int biz_copy_string(const cJSON *obj, const char *key, char *out, size_t cap, bool required) +{ + const cJSON *v = cJSON_GetObjectItemCaseSensitive(obj, key); + out[0] = '\0'; + if (!v || cJSON_IsNull(v)) return required ? OPRT_INVALID_RESULT : OPRT_OK; + if (!cJSON_IsString(v) || strlen(v->valuestring) >= cap || + (required && !v->valuestring[0])) return OPRT_INVALID_RESULT; + strcpy(out, v->valuestring); + return OPRT_OK; +} diff --git a/modules/biz-client/src/service_internal.h b/modules/biz-client/src/service_internal.h new file mode 100644 index 0000000..d09eea5 --- /dev/null +++ b/modules/biz-client/src/service_internal.h @@ -0,0 +1,20 @@ +#ifndef BIZ_SERVICE_INTERNAL_H +#define BIZ_SERVICE_INTERNAL_H +#include "tuya_biz_client.h" +#include "iot_atop.h" +#include "cJSON.h" + +struct tuya_biz_client { + iot_client_t *iot; + const pal_t *pal; + char request_lang[32]; +}; + +int biz_prepare(tuya_biz_client_t *, iot_atop_rejection_t *); +cJSON *biz_query_body(const tuya_biz_client_t *); +/* Consumes body on ALL paths. Returns a caller-owned parsed result. */ +int biz_call(tuya_biz_client_t *, const char *api, cJSON *body, + cJSON **result, iot_atop_rejection_t *); +int biz_copy_string(const cJSON *, const char *, char *, size_t, bool required); +bool biz_valid_string(const char *, size_t capacity); +#endif diff --git a/modules/biz-client/test/ai_role_test.c b/modules/biz-client/test/ai_role_test.c new file mode 100644 index 0000000..db11c47 --- /dev/null +++ b/modules/biz-client/test/ai_role_test.c @@ -0,0 +1,290 @@ +/* Typed service contract tests. The ATOP boundary is faked; SDK generic-call + * encryption/TLS/envelope behavior remains covered by iot_atop_call_test. */ +#include "tuya_ai_role.h" +#include "iot_atop.h" +#include "cJSON.h" +#include +#include +#include +#include +#include + +static int calls, frees, live, fail_after = -1; +static int response_rc; +static const char *response_json; +static char api[128]; +static cJSON *request_body; +static pal_t pal; +static iot_client_t iot; +static tuya_biz_client_t *biz; +#define CHECK(x) do { if (!(x)) { fprintf(stderr, "%s:%d: %s\n", __func__, __LINE__, #x); return 1; } } while (0) + +static void *allocate(size_t n) +{ + if (fail_after == 0) return NULL; + if (fail_after > 0) --fail_after; + void *p = malloc(n); + if (p) ++live; + return p; +} +static void release(void *p) { if (p) { --live; free(p); } } + +int iot_atop_call(iot_client_t *client, const iot_atop_request_t *req, + iot_atop_response_t *out) +{ + (void)client; + ++calls; + memset(out, 0, sizeof(*out)); + snprintf(api, sizeof(api), "%s", req->api); + if (strcmp(req->version, "1.0")) return OPRT_INVALID_PARAMETER; + cJSON_Delete(request_body); + request_body = cJSON_Parse(req->data); + if (response_rc == OPRT_ATOP_BUSINESS_ERROR) { + strcpy(out->error_code, "ROLE_NOT_ALLOWED"); + strcpy(out->error_msg, "role unavailable"); + } + if (response_json) { + out->result = allocate(strlen(response_json) + 1); + if (!out->result) return OPRT_MALLOC_FAILED; + strcpy(out->result, response_json); + } + return response_rc; +} +void iot_atop_response_free(iot_client_t *client, iot_atop_response_t *out) +{ + (void)client; ++frees; release(out->result); memset(out, 0, sizeof(*out)); +} +static const char *str(const char *key) +{ + const cJSON *v = cJSON_GetObjectItemCaseSensitive(request_body, key); + return cJSON_IsString(v) ? v->valuestring : ""; +} +static int number(const char *key) +{ + const cJSON *v = cJSON_GetObjectItemCaseSensitive(request_body, key); + return cJSON_IsNumber(v) ? v->valueint : -1; +} +static void response(const char *json) { response_rc = OPRT_OK; response_json = json; } + +static int lifecycle(void) +{ + tuya_biz_client_t *out = (void *)1; + CHECK(tuya_biz_client_create(NULL, NULL, &out) == OPRT_INVALID_PARAMETER); + CHECK(out == NULL); + CHECK(tuya_biz_client_create(&iot, "", &out) == OPRT_INVALID_PARAMETER); + fail_after = 0; + CHECK(tuya_biz_client_create(&iot, NULL, &out) == OPRT_MALLOC_FAILED); + fail_after = -1; + CHECK(tuya_biz_client_create(&iot, "en", &out) == OPRT_OK); + response("[]"); tuya_ai_role_list_t list = {0}; + CHECK(tuya_ai_role_list_templates(out, &list, NULL) == OPRT_OK); + CHECK(strcmp(str("requestLang"), "en") == 0); + tuya_ai_role_list_free(out, &list); + tuya_biz_client_destroy(out); + tuya_biz_client_destroy(NULL); + return 0; +} + +static int bound_and_templates(void) +{ + response("{\"roleId\":\"r1\",\"bindRoleType\":1,\"roleName\":\"Buddy\",\"useLangCode\":\"zh_ha\",\"speed\":1.2}"); + tuya_ai_role_t role; + CHECK(tuya_ai_role_get_bound(biz, &role, NULL) == OPRT_OK); + CHECK(!strcmp(api, "thing.ai.agent.role.get-bind-role")); + CHECK(!strcmp(role.role_id, "r1") && role.type == TUYA_AI_ROLE_TEMPLATE); + CHECK(!strcmp(role.lang_code, "zh_ha") && role.speed == 1.2); + CHECK(number("t") > 1700000000 && !strcmp(str("requestLang"), "zh")); + response("[{\"roleId\":\"template-1\"}]"); + tuya_ai_role_list_t list = {0}; + CHECK(tuya_ai_role_list_templates(biz, &list, NULL) == OPRT_OK); + CHECK(list.count == 1 && list.items[0].type == TUYA_AI_ROLE_TEMPLATE); + CHECK(list.items[0].speed == 1.0); + tuya_ai_role_list_free(biz, &list); + CHECK(!list.items && list.count == 0); + tuya_ai_role_list_free(biz, &list); + return 0; +} + +static int list_shapes_and_pagination(void) +{ + const char *keys[] = {"datas", "list", "records", "rows", "content", "data", "items"}; + for (size_t i = 0; i < sizeof(keys)/sizeof(keys[0]); ++i) { + char json[160]; + snprintf(json, sizeof(json), "{\"%s\":[{\"roleId\":\"c1\"}],\"totalCount\":35}", keys[i]); + response(json); tuya_ai_role_list_t list = {0}; + CHECK(tuya_ai_role_list_custom(biz, 2, 7, &list, NULL) == OPRT_OK); + CHECK(!strcmp(api, "thing.ai.agent.role.custom-role.page")); + CHECK(number("pageNo") == 2 && number("pageSize") == 7); + CHECK(list.total_count == 35 && list.items[0].type == TUYA_AI_ROLE_CUSTOM); + tuya_ai_role_list_free(biz, &list); + } + response("[]"); tuya_ai_role_list_t list = {0}; + CHECK(tuya_ai_role_list_custom(biz, 1, 1, &list, NULL) == OPRT_OK); + CHECK(!list.items && !list.count && list.total_count == -1); + int before = calls; + CHECK(tuya_ai_role_list_custom(biz, 0, 20, &list, NULL) == OPRT_INVALID_PARAMETER); + CHECK(tuya_ai_role_list_custom(biz, 1, 0, &list, NULL) == OPRT_INVALID_PARAMETER); + CHECK(calls == before); + return 0; +} + +static int bind_and_delete(void) +{ + response("true"); + CHECK(tuya_ai_role_bind(biz, TUYA_AI_ROLE_TEMPLATE, "id\"quoted", NULL) == OPRT_OK); + CHECK(!strcmp(api, "thing.ai.agent.role.bind-with-role")); + CHECK(number("bindRoleType") == 1 && !strcmp(str("roleId"), "id\"quoted")); + CHECK(tuya_ai_role_delete_custom(biz, "c1", NULL) == OPRT_OK); + CHECK(!strcmp(api, "thing.ai.agent.role.custom-role.delete")); + CHECK(!strcmp(str("roleId"), "c1")); + return 0; +} + +static int template_edit_creates_and_binds(void) +{ + tuya_ai_role_t cur = { .type = TUYA_AI_ROLE_TEMPLATE }; + strcpy(cur.role_id, "template-1"); + strcpy(cur.template_id, "not-the-role-id"); + tuya_ai_role_edit_t edit = { .name = "Hello \"friend\"", .lang_code = "zh_ha", + .timbre_id = "voice-1", .set_speed = true, .speed = 1.2 }; + char id[TUYA_AI_ROLE_ID_LEN]; + response("\"new-custom\""); + CHECK(tuya_ai_role_apply_edit(biz, &cur, &edit, id, NULL) == OPRT_OK); + CHECK(!strcmp(api, "thing.ai.agent.role.custom-role.create-from-template")); + CHECK(!strcmp(id, "new-custom") && !strcmp(str("roleId"), "template-1")); + CHECK(!strcmp(str("roleName"), edit.name)); + CHECK(!strcmp(str("useLangCode"), "zh_ha") && !strcmp(str("speed"), "1.20")); + CHECK(cJSON_IsTrue(cJSON_GetObjectItemCaseSensitive(request_body, "needBind"))); + return 0; +} + +static int custom_edit_preserves_omitted_fields(void) +{ + tuya_ai_role_t cur = { .type = TUYA_AI_ROLE_CUSTOM }; + strcpy(cur.role_id, "custom-1"); + tuya_ai_role_edit_t edit = { .name = "New name" }; + char id[TUYA_AI_ROLE_ID_LEN]; response("true"); + CHECK(tuya_ai_role_apply_edit(biz, &cur, &edit, id, NULL) == OPRT_OK); + CHECK(!strcmp(api, "thing.ai.agent.role.custom-role.update") && !strcmp(id, "custom-1")); + CHECK(!cJSON_HasObjectItem(request_body, "needBind")); + CHECK(!cJSON_HasObjectItem(request_body, "useLangCode")); + CHECK(!cJSON_HasObjectItem(request_body, "speed")); + response("false"); + CHECK(tuya_ai_role_apply_edit(biz, &cur, &edit, id, NULL) == OPRT_INVALID_RESULT); + CHECK(id[0] == 0); + return 0; +} + +static int languages_and_timbres(void) +{ + response("[{\"langCode\":\"zh_ha\",\"langName\":\"Dialect\",\"hasDefault\":true}]"); + tuya_ai_language_list_t langs = {0}; + CHECK(tuya_ai_role_list_languages(biz, &langs, NULL) == OPRT_OK); + CHECK(!strcmp(api, "thing.ai.agent.config.languages.list")); + CHECK(langs.count == 1 && !strcmp(langs.items[0].code, "zh_ha") && langs.items[0].has_default); + tuya_ai_language_list_free(biz, &langs); + response("{\"datas\":[{\"voiceId\":\"v1\",\"voiceName\":\"Voice\",\"supportLangs\":[\"zh_ha\",\"en_US\"]}],\"totalCount\":77}"); + tuya_ai_timbre_list_t voices = {0}; + CHECK(tuya_ai_role_list_timbres(biz, "zh_ha", 2, 3, &voices, NULL) == OPRT_OK); + CHECK(!strcmp(api, "thing.ai.timbre.market.page")); + CHECK(!strcmp(str("lang"), "zh_ha") && number("pageNo") == 2 && number("pageSize") == 3); + CHECK(voices.total_count == 77 && !strcmp(voices.items[0].support_langs, "zh_ha,en_US")); + tuya_ai_timbre_list_free(biz, &voices); + return 0; +} + +static int malformed_results_and_rejections(void) +{ + const char *bad[] = {NULL, "null", "{}", "{", "[]junk", "false", "[{}]", "{\"datas\":{}}"}; + for (size_t i = 0; i < sizeof(bad)/sizeof(bad[0]); ++i) { + response(bad[i]); tuya_ai_role_list_t list = {0}; + CHECK(tuya_ai_role_list_templates(biz, &list, NULL) == OPRT_INVALID_RESULT); + CHECK(!list.items && !list.count); + } + response_rc = OPRT_ATOP_BUSINESS_ERROR; response_json = NULL; + tuya_ai_role_t role; iot_atop_rejection_t rejection; + CHECK(tuya_ai_role_get_bound(biz, &role, &rejection) == OPRT_ATOP_BUSINESS_ERROR); + CHECK(!strcmp(rejection.code, "ROLE_NOT_ALLOWED") && role.role_id[0] == 0); + response_rc = OPRT_COMMUNICATION_ERROR; + CHECK(tuya_ai_role_get_bound(biz, &role, &rejection) == OPRT_COMMUNICATION_ERROR); + CHECK(rejection.code[0] == 0); + response("{}"); + CHECK(tuya_ai_role_get_bound(biz, &role, NULL) == OPRT_INVALID_RESULT); + CHECK(calls == frees); + return 0; +} + +static int invalid_arguments_do_not_call_cloud(void) +{ + int before = calls; char id[TUYA_AI_ROLE_ID_LEN]; + tuya_ai_role_t cur = { .type = TUYA_AI_ROLE_TEMPLATE }; + strcpy(cur.role_id, "t1"); + tuya_ai_role_edit_t edit = {0}; + CHECK(tuya_ai_role_apply_edit(biz, &cur, &edit, id, NULL) == OPRT_INVALID_PARAMETER); + edit.set_speed = true; edit.speed = NAN; + CHECK(tuya_ai_role_apply_edit(biz, &cur, &edit, id, NULL) == OPRT_INVALID_PARAMETER); + edit.speed = -1; + CHECK(tuya_ai_role_apply_edit(biz, &cur, &edit, id, NULL) == OPRT_INVALID_PARAMETER); + edit.speed = 1; cur.type = TUYA_AI_ROLE_SINGLE; + CHECK(tuya_ai_role_apply_edit(biz, &cur, &edit, id, NULL) == OPRT_INVALID_PARAMETER); + CHECK(tuya_ai_role_bind(biz, (tuya_ai_role_type_t)8, "x", NULL) == OPRT_INVALID_PARAMETER); + CHECK(tuya_ai_role_delete_custom(biz, "", NULL) == OPRT_INVALID_PARAMETER); + CHECK(tuya_ai_role_get_bound(biz, NULL, NULL) == OPRT_INVALID_PARAMETER); + CHECK(calls == before); + return 0; +} + +static int failed_creation_and_overlong_fields(void) +{ + tuya_ai_role_t cur = { .type = TUYA_AI_ROLE_TEMPLATE }; strcpy(cur.role_id, "tpl"); + tuya_ai_role_edit_t edit = { .name = "Renamed" }; char id[TUYA_AI_ROLE_ID_LEN]; + const char *bad_ids[] = {"true", "{}", "\"\"", "null"}; + for (size_t i = 0; i < sizeof(bad_ids)/sizeof(bad_ids[0]); ++i) { + response(bad_ids[i]); + CHECK(tuya_ai_role_apply_edit(biz, &cur, &edit, id, NULL) == OPRT_INVALID_RESULT); + CHECK(!id[0]); + } + char json[256]; memset(json, 'x', sizeof(json)); + snprintf(json, sizeof(json), "[{\"roleId\":\"%080d\"}]", 1); + response(json); tuya_ai_role_list_t list = {0}; + CHECK(tuya_ai_role_list_templates(biz, &list, NULL) == OPRT_INVALID_RESULT); + CHECK(!list.items && !list.count); + return 0; +} + +static int allocation_failures_release_everything(void) +{ + response("[{\"roleId\":\"t1\"}]"); + cJSON_Delete(request_body); request_body = NULL; + int baseline = live; bool succeeded = false; + for (int n = 0; n < 90; ++n) { + fail_after = n; + tuya_ai_role_list_t list = {0}; + int rc = tuya_ai_role_list_templates(biz, &list, NULL); + fail_after = -1; + CHECK(rc == OPRT_OK || rc == OPRT_MALLOC_FAILED || rc == OPRT_INVALID_RESULT); + tuya_ai_role_list_free(biz, &list); + cJSON_Delete(request_body); request_body = NULL; + CHECK(live == baseline); + if (rc == OPRT_OK) { succeeded = true; break; } + } + CHECK(succeeded); + return 0; +} + +int main(void) +{ + cJSON_Hooks hooks = {allocate, release}; cJSON_InitHooks(&hooks); + pal.malloc = allocate; pal.free = release; iot.pal = &pal; + if (tuya_biz_client_create(&iot, NULL, &biz) != OPRT_OK) return 1; + int (*tests[])(void) = { lifecycle, bound_and_templates, list_shapes_and_pagination, + bind_and_delete, template_edit_creates_and_binds, custom_edit_preserves_omitted_fields, + languages_and_timbres, malformed_results_and_rejections, invalid_arguments_do_not_call_cloud, + failed_creation_and_overlong_fields, allocation_failures_release_everything }; + int failed = 0; + for (size_t i = 0; i < sizeof(tests)/sizeof(tests[0]); ++i) failed += tests[i](); + cJSON_Delete(request_body); tuya_biz_client_destroy(biz); + if (live) { fprintf(stderr, "unreleased allocations: %d\n", live); ++failed; } + printf("biz-client: %zu cases, %d failed\n", sizeof(tests)/sizeof(tests[0]), failed); + return failed ? 1 : 0; +}