From 31ffd15a3f263190de214f90ee49fcdb05953be2 Mon Sep 17 00:00:00 2001 From: xiong Date: Tue, 20 Oct 2026 08:27:04 +0800 Subject: [PATCH 1/2] feat(biz-client): add portable AI role management Share cloud role behavior across device products without mixing it into IoT activation/transport or RTC Session ownership. Borrow an IoT client and its PAL, use only the public ATOP entry point, preserve cloud rejections, validate typed query results and expose explicit list release and pagination contracts. Template edits create and bind a custom role; custom edits update in place without rebinding. Add value-copy ESP-IDF worker integration and a standalone POSIX CLI, with bilingual guides/reference and host/ESP-IDF build registration. The CLI skips version reporting when operating an existing device. Verified: 21/21 SDK ctest targets; 11 role contract cases with UBSan; POSIX CLI build/help; ESP32-S3 ESP-IDF 5.5 build of module and example; both documentation locales and documentation prebuild checks. Live cloud role/voice acceptance remains an application integration check. Co-Authored-By: Codex --- .gitlab-ci.yml | 3 +- CHANGELOG.md | 1 + CMakeLists.txt | 34 ++ CONTEXT-MAP.md | 4 + README.md | 1 + README_en.md | 1 + docs-site/docs/guides/ai-role-management.md | 128 +++++++ docs-site/docs/reference/biz-client.md | 69 ++++ .../current/guides/ai-role-management.md | 163 +++++++++ .../current/reference/biz-client.md | 96 +++++ docs-site/sidebars.ts | 2 + .../esp-idf/ai/role-management/CMakeLists.txt | 7 + examples/esp-idf/ai/role-management/README.md | 63 ++++ .../ai/role-management/role_management.c | 119 +++++++ .../ai/role-management/role_management.h | 36 ++ .../components/agentic_kit/CMakeLists.txt | 5 + examples/posix/CMakeLists.txt | 3 + .../posix/ai/role-management/CMakeLists.txt | 13 + examples/posix/ai/role-management/README.md | 41 +++ examples/posix/ai/role-management/role_demo.c | 119 +++++++ examples/posix/ai/role-management/role_demo.h | 7 + .../posix/ai/role-management/role_demo_main.c | 67 ++++ modules/biz-client/CONTEXT.md | 40 +++ .../0001-business-services-use-public-atop.md | 23 ++ modules/biz-client/include/tuya_ai_role.h | 113 ++++++ modules/biz-client/include/tuya_biz_client.h | 28 ++ modules/biz-client/src/ai_role.c | 331 ++++++++++++++++++ modules/biz-client/src/biz_client.c | 101 ++++++ modules/biz-client/src/service_internal.h | 20 ++ modules/biz-client/test/ai_role_test.c | 290 +++++++++++++++ 30 files changed, 1927 insertions(+), 1 deletion(-) create mode 100644 docs-site/docs/guides/ai-role-management.md create mode 100644 docs-site/docs/reference/biz-client.md create mode 100644 docs-site/i18n/en/docusaurus-plugin-content-docs/current/guides/ai-role-management.md create mode 100644 docs-site/i18n/en/docusaurus-plugin-content-docs/current/reference/biz-client.md create mode 100644 examples/esp-idf/ai/role-management/CMakeLists.txt create mode 100644 examples/esp-idf/ai/role-management/README.md create mode 100644 examples/esp-idf/ai/role-management/role_management.c create mode 100644 examples/esp-idf/ai/role-management/role_management.h create mode 100644 examples/posix/ai/role-management/CMakeLists.txt create mode 100644 examples/posix/ai/role-management/README.md create mode 100644 examples/posix/ai/role-management/role_demo.c create mode 100644 examples/posix/ai/role-management/role_demo.h create mode 100644 examples/posix/ai/role-management/role_demo_main.c create mode 100644 modules/biz-client/CONTEXT.md create mode 100644 modules/biz-client/docs/adr/0001-business-services-use-public-atop.md create mode 100644 modules/biz-client/include/tuya_ai_role.h create mode 100644 modules/biz-client/include/tuya_biz_client.h create mode 100644 modules/biz-client/src/ai_role.c create mode 100644 modules/biz-client/src/biz_client.c create mode 100644 modules/biz-client/src/service_internal.h create mode 100644 modules/biz-client/test/ai_role_test.c 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..1b3b552 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. - 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; +} From 874cc6507354210bed92d151e0d3d706726f0f26 Mon Sep 17 00:00:00 2001 From: xiong Date: Tue, 20 Oct 2026 08:34:27 +0800 Subject: [PATCH 2/2] docs(biz-client): link role management release note to PR 47 Record the upstream review link required by the release-note convention. Verified: git diff --check; documentation-only reference change. Co-Authored-By: Codex --- CHANGELOG.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 1b3b552..d0f0e46 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -9,7 +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. +- 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).