From ba4468fd21604124eca18c6510dc6fc4cb944c22 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E4=BD=95=E5=B0=91=E7=90=BC?= Date: Tue, 11 Aug 2026 15:54:13 +0800 Subject: [PATCH] refactor(examples)!: share JSON/text/MCP helpers in rtc-tcp-client demos MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The same ~170-line block of JSON readers, base64 decode and session-token parsing sat in all five POSIX demos, so the same bugs sat in all five too, and the copies had begun to diverge (json_get_object vs json_get_object_raw, json_get_long present in some, different parse_token diagnostics). It moves into headers next to the existing demo_reconnect.h so a fix lands once: demo_json.h JSON readers, base64, parse_token(), bounded field copy demo_text.h tai_text_msg_t handling and stream reassembly demo_mcp.h device-side MCP request answering The shared version takes the superset of what the copies drifted into, so no demo loses behaviour. All five migrate onto it. The fixes below belong in the shared code rather than in five places: - tai_text_msg_t.text is a borrowed slice of the SDK receive buffer and is explicitly NOT NUL-terminated, but the demos ran strstr/strchr over it, reading past msg->len and eventually past the end of the tai_ctx_t allocation. All text handling is length-bounded or copies the bytes out. - devid / secret_key / local_key from argv were memcpy'd into iot_client_config_t's 32-byte fields with no length check, overflowing the stack-local config. demo_copy_field() rejects a value that does not fit. - The scanners are string- and escape-aware: a delimiter inside a JSON string no longer terminates a span (a track titled "Best Of ]" used to truncate the audios array), and \" \\ \/ \uXXXX with surrogate pairs decode, while NUL and lone surrogates are rejected. NLG prose is decoded before printing too, where \uXXXX-escaped Chinese used to reach the terminal as escapes. - A value that does not fit is rejected rather than truncated, since half a credential is worse than none — except for fields that are only printed, which truncate via json_get_display_string(): a song title past 255 bytes rendered as "(unknown)". parse_token() tells capacity apart from a wrong type and a malformed escape, and an out-of-range port is rejected instead of truncated modulo 65536. - demo_textbuf_accum() reassembles START/MIDDLE/END before a caller parses, which is what lets the music demo recognise a SKILL response split across chunks — parsed per chunk it never matched at all. The SDK drops empty text frames, so a stream ended by a bare zero-length END never completes; demo_textbuf_flush() delivers it at end-of-turn. - Two streams interleaving inside a turn cannot be demuxed (same event_id, constant data_id), so each loss is announced and counted in demo_textbuf_t.dropped; music_play_demo exits non-zero on it rather than reporting "no music skill response" and exiting 0 for a run that lost its payload. A stream displaced by a new START used to vanish without a word. A seq gap now warns and keeps accumulating, because the empty frames the SDK swallows consume a seq while carrying no bytes; DEMO_TEXT_SEQ_CHECK=2 drops instead, =0 skips the check. - Four demos answered every TAI_EVT_MCP_CMD with one canned reply that hardcoded "id":1 — JSON-RPC correlates on the id — and always used the tools/call result shape, so initialize and tools/list got the wrong body. Opting out is not available: the SDK's default session attributes declare deviceMcp.supportCustomMCP. demo_mcp_reply_no_tools() answers as a device with an empty catalog and stays silent for an id-less request, which is a notification (mcp_demo too, which used to answer "id":null). demo_mcp_copy_id() reads id and method from the top-level members only — a tools/call may carry an "id" of its own inside params.arguments — and refuses any id it cannot echo verbatim: an over-long quoted one (spliced in without its closing quote before) or an object/array one. Marked ! because two contracts change: json_get_string() fails instead of truncating (json_get_display_string() is the opt-in for printed values), and json_get_object_raw/json_get_array_raw are spelled json_get_object and json_get_array. All POSIX example targets build clean under -Wall -Wextra -Wshadow. Co-Authored-By: Claude Opus 5 (1M context) --- .gitignore | 1 + CHANGELOG.md | 79 +++ docs-site/docs/guides/device-mcp.md | 24 +- docs-site/docs/tutorials/edu-camera.md | 2 +- docs-site/docs/tutorials/music-play.md | 71 ++- .../posix/ai/rtc-tcp-client/audio_chat_demo.c | 179 +----- examples/posix/ai/rtc-tcp-client/demo_json.h | 556 ++++++++++++++++++ examples/posix/ai/rtc-tcp-client/demo_mcp.h | 139 +++++ examples/posix/ai/rtc-tcp-client/demo_text.h | 298 ++++++++++ .../posix/ai/rtc-tcp-client/edu_camera_demo.c | 187 +----- examples/posix/ai/rtc-tcp-client/mcp_demo.c | 286 +-------- .../posix/ai/rtc-tcp-client/music_play_demo.c | 411 ++++--------- .../posix/ai/rtc-tcp-client/text_chat_demo.c | 208 +------ 13 files changed, 1342 insertions(+), 1099 deletions(-) create mode 100644 examples/posix/ai/rtc-tcp-client/demo_json.h create mode 100644 examples/posix/ai/rtc-tcp-client/demo_mcp.h create mode 100644 examples/posix/ai/rtc-tcp-client/demo_text.h diff --git a/.gitignore b/.gitignore index b0855a9..ec60e26 100644 --- a/.gitignore +++ b/.gitignore @@ -8,6 +8,7 @@ build-* venv .cache *.pcm +*.mp3 *.o third_party/* !third_party/mbedtls diff --git a/CHANGELOG.md b/CHANGELOG.md index b82a782..2112476 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -32,6 +32,26 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ### Changed +- Examples — the rtc-tcp-client POSIX demos share their parsing helpers. + - `demo_json.h` (JSON readers, base64, session-token parsing, bounded copy + into fixed-size config fields), `demo_text.h` (`tai_text_msg_t` handling + and stream reassembly) and `demo_mcp.h` (device-side MCP answering) + replace the copy of that code each of the five demos carried, alongside + the existing `demo_reconnect.h`. + - The shared JSON readers are string- and escape-aware: a `{`, `}`, `[`, `]` + or `"` inside a JSON string no longer terminates a span, and `\"`, `\/` + and `\uXXXX` (including surrogate pairs) decode instead of truncating the + value. A value that does not fit its buffer now reports failure rather than + being silently truncated, and `parse_token` names the field when that + happens — an empty `derived_client_id` / `agentToken` otherwise surfaced + only as an unexplained auth failure. An out-of-range port is rejected + instead of being truncated modulo 65536. + - The music demo leaves `session_attrs_json` / `event_user_data_json` NULL + instead of spelling out a subset of the built-in defaults. Setting either + replaces the default wholesale rather than merging, so the subset was + silently dropping `tts.order.supports`, `asr.enableVad`, `tts.alternate` + and `processing.interrupt`. + - iot-client — `iot_get_qrcode_info` and `iot_get_ca_certificate` now write into caller-provided buffers (API break)(#10). - The single-field `iot_qrcode_response_t` struct is removed and both APIs @@ -52,6 +72,65 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ### Fixed +- Examples — the tool-less rtc-tcp-client demos answer MCP requests correctly. + text_chat, audio_chat, edu_camera and music_play each answered every + `TAI_EVT_MCP_CMD` with one canned reply that hardcoded `"id":1` — JSON-RPC + correlates a response to its request by echoing the id — and always used the + `tools/call` result shape, so the `initialize` handshake and `tools/list` + were answered with the wrong body. Opting out is not an option: the SDK's + built-in default session attributes declare `deviceMcp.supportCustomMCP`, so + a device that passes no `session_attrs_json` is asked anyway. + - New `demo_mcp.h` answers as a device with an empty tool catalog: it echoes + the request id, returns the right result shape per method + (`initialize` / `tools/list` / `tools/call`), reports unknown methods as + JSON-RPC `-32601`, and stays silent for a request with no id, which is a + notification. Its `demo_mcp_copy_id()` also replaces `mcp_demo`'s local + copy, where an id too long for the buffer used to be spliced in truncated + — dropping its closing quote and producing unparseable JSON. + - `id` and `method` are read from the request's top-level members only: a + `tools/call` may carry an `"id"` of its own inside `params.arguments`, + which a document-order search finds first. An object or array id is + refused rather than spliced back unbalanced. + - `mcp_demo` implements real tools, and now stays silent for notifications + instead of answering `"id":null`. + +- Examples — text streams that cannot be reassembled are reported, not dropped + in silence. Each loss is counted in `demo_textbuf_t.dropped`, and + `music_play_demo` exits non-zero on it rather than reporting "no music skill + response" and exiting 0 for a run that lost its payload. A stream displaced by + a new `START` used to vanish without a word. A `seq` gap now warns and keeps + accumulating — the empty frames the SDK swallows consume a seq while carrying + no bytes, so continuing reassembles the right document where dropping loses a + healthy one; `-DDEMO_TEXT_SEQ_CHECK=2` drops instead, `=0` skips the check. + +- Examples — NLG prose is unescaped before printing. `nlg_print_content()` + decodes `\n` and `\uXXXX` (Chinese arrived on the terminal as escapes) and + claims the empty terminator line `{"content":""}`, which used to fall through + and dump a whole JSON envelope into the middle of the prose. + +- Examples — a value too long for a field that is only printed truncates + instead of being emptied: `music_play_demo` showed `Song: (unknown)` for a + title past 255 bytes and dropped long cover URLs entirely. Credentials still + reject. `parse_token` tells capacity apart from a wrong type and a bad escape, + and an audio URL that does not fit is a parse failure, not a success with no + URL. + +- Examples — out-of-bounds read on received text in the rtc-tcp-client demos. + `tai_text_msg_t.text` is a borrowed slice of the SDK receive buffer and is not + NUL-terminated, but the demos ran `strstr`/`strchr` over it — reading past + `msg->len` into the previous packet's bytes and, eventually, past the end of + the `tai_ctx_t` allocation. All text handling is now length-bounded or copies + the bytes out first. Reassembly also lets the music demo recognise a SKILL + response split across `TAI_STREAM_START`/`MIDDLE`/`END` — parsed per chunk + it never matched at all. + +- Examples — stack overflow from `argv` credentials in the rtc-tcp-client demos. + `devid` / `secret_key` / `local_key` were `memcpy`'d into + `iot_client_config_t`'s 32-byte fields with no length check, so an over-long + value overwrote the adjacent fields and ran past the end of the stack-local + config. All five demos now go through `demo_copy_field()`, which rejects a + value that does not fit. + - iot-client — US region renamed to AZ(#7). - The IoT DNS region string and token prefix for the US West (Oregon) data center is `AZ`, not `US`. The enum member `US` is renamed to `AZ`, diff --git a/docs-site/docs/guides/device-mcp.md b/docs-site/docs/guides/device-mcp.md index ad93e07..a34fe97 100644 --- a/docs-site/docs/guides/device-mcp.md +++ b/docs-site/docs/guides/device-mcp.md @@ -140,11 +140,23 @@ static void on_event(tai_ctx_t *ctx, const tai_event_msg_t *msg, void *ud) static void handle_mcp_request(tai_ctx_t *ctx, const char *payload, size_t len) { - // 解析 method 和 id - char id[64] = "null"; + // msg->data 借用自 SDK 接收缓冲区且没有 '\0' 结尾,而 demo_json.h + // 的所有函数都要求 NUL 结尾的缓冲区:先按 len 拷出一份 + char *req = (char *)malloc(len + 1); + if (!req) return; + memcpy(req, payload, len); + req[len] = '\0'; + + // 解析 method 和 id —— 只取顶层成员:tools/call 的 + // params.arguments 里可能自带 "id" / "method",按文档顺序 + // 搜索会先命中那一个,回显错的 id 会让服务端无法关联响应 + char id[64]; char method[64] = {0}; - copy_id(payload, id, sizeof(id)); - json_get_string(payload, "method", method, sizeof(method)); + int have_id = (demo_mcp_copy_id(req, id, sizeof(id)) == 0); + json_object_get_string(req, "method", method, sizeof(method)); + + // 没有 id 就是通知(notification),JSON-RPC 2.0 规定不得应答 + if (!have_id) { free(req); return; } char resp[2048]; int resp_len = 0; @@ -167,6 +179,7 @@ static void handle_mcp_request(tai_ctx_t *ctx, if (resp_len > 0) { tai_send_mcp_response(ctx, resp); } + free(req); } ``` @@ -283,7 +296,8 @@ static int tool_read_sensor(const char *args_json, ## 注意事项 - 所有回调(包括 `TAI_EVT_MCP_CMD`)在后台接收线程中执行,工具函数应避免长时间阻塞 -- 响应的 `id` 必须与请求的 `id` 完全一致,否则云端无法匹配 +- 响应的 `id` 必须与请求的 `id` 完全一致,否则云端无法匹配。这里有两个容易踩的坑:一是 `id` 只能取**顶层**的那一个,`params.arguments` 里业务自带的 `id`(灯的 id、歌曲 id)会被"取第一个匹配"的写法先命中;二是 `id` 只能原样回填,截断一个带引号的 id 会丢掉右引号,把对象或数组形式的值截一半更会产生括号不配对的 JSON。`demo_mcp.h` 的 `demo_mcp_copy_id()` 两者都已处理 +- 请求里**没有** `id` 就是通知(notification),JSON-RPC 2.0 规定不得对其应答——包括不要回 `"id":null` 的错误响应 - `tai_send_mcp_response()` 的参数是完整的 JSON-RPC 2.0 响应字符串(非 `result` 部分) - 工具输出中的双引号和反斜杠需要转义 - 响应缓冲区大小需根据工具输出长度合理设置,避免截断 diff --git a/docs-site/docs/tutorials/edu-camera.md b/docs-site/docs/tutorials/edu-camera.md index 3dbfad8..e5dad7a 100644 --- a/docs-site/docs/tutorials/edu-camera.md +++ b/docs-site/docs/tutorials/edu-camera.md @@ -1,7 +1,7 @@ --- title: 拍学机(图片理解) sidebar_label: 图片理解 -sidebar_position: 3 +sidebar_position: 4 --- # 拍学机(图片理解) diff --git a/docs-site/docs/tutorials/music-play.md b/docs-site/docs/tutorials/music-play.md index 44d569c..7d86152 100644 --- a/docs-site/docs/tutorials/music-play.md +++ b/docs-site/docs/tutorials/music-play.md @@ -1,7 +1,7 @@ --- title: 音乐播放 sidebar_label: 音乐播放 -sidebar_position: 4 +sidebar_position: 3 --- # 音乐播放 @@ -187,24 +187,74 @@ AI 触发音乐技能后,会通过 `on_text` 回调返回结构化的 SKILL } ``` -示例在 `on_text` 回调中检测 `"code":"music"`,然后逐层提取 `general.data.audios[0]` 中的歌曲字段: +示例的 `on_text` 回调做两件事:NLG 文本逐片即时打印(保持流式体验),同时把所有分片累积起来,流结束后再解析 SKILL 结构: ```c static void on_text(tai_ctx_t *ctx, const tai_text_msg_t *msg, void *ud) { - if (strstr(msg->text, "\"code\":\"music\"")) { - try_parse_music(msg->text, msg->len); - dc->got_music = 1; - return; - } - /* NLG 文本:仅打印 content 字段 */ - ... + demo_ctx_t *dc = (demo_ctx_t *)ud; + + /* NLG 文本:每个分片自成一行 JSON,到达即打印(按 msg->len 截断, + 并解码 \n / \" / \uXXXX 转义)。返回 1 表示这片是 NLG 且已处理, + 包括 {"content":""} 这样的空结束片——它仍然是 NLG,不能再按原样打印 */ + if (nlg_print_content(msg->text, msg->len)) + dc->stream_printed = 1; + + /* 同时累积整个流:SKILL 响应是一份 JSON,可能跨分片,拼完整才解析 */ + if (demo_textbuf_accum(&dc->text, msg) == 1) + handle_complete_text(dc); /* is_music_response → try_parse_music */ } ``` +服务端可能不发独立的文本 END 分片(SDK 会丢弃空文本帧),因此 `on_event` 在收到 `TAI_EVT_END`(回合结束)时调用 `demo_textbuf_flush()` 兜底交付缓冲中的流。 + +:::caution 两个必须注意的约束 +- **`msg->text` 没有 `\0` 结尾**。`tuya_ai.h` 中明确标注该指针借用自 SDK 接收缓冲区且非 NUL 结尾,对它直接调用 `strstr` / `strchr` / `strcmp` 会越过 `msg->len` 读到上一个数据包的残留字节。所有解析都必须先按 `msg->len` 把数据拷出来。 +- **文本按 `stream_flag` 分片下发**(`TAI_STREAM_START` / `MIDDLE` / `END`,或单个 `ONE_SHOT`)。只做打印的场景可以逐片处理,但解析 JSON 结构必须先重组整个流,否则 `"code":"music"` 与 `audios` 可能落在不同分片里。 + +这两件事由 `demo_text.h` 的 `demo_textbuf_accum()` / `demo_textbuf_flush()` 统一处理;断线重连前用 `demo_textbuf_reset()` 丢弃旧连接的半截流。 +::: + +:::info 缓冲区只装一条流 +`demo_textbuf_t` 一次只重组一条文本流,也**无法**分离回合内交错的两条流——`tai_text_msg_t` 里没有可用于分路的字段:同一回合内所有文本包共享同一个 `event_id`(SDK 只 latch 一个回合 id,`TAI_EVT_END` 后清空)和同一个 `data_id`(`TAI_DATA_ID_TEXT_DOWN`)。 + +能做的是**察觉**,分两种情况: + +**一条流被新流顶掉**——上一条流还没收到 END,就来了 `START` / `ONE_SHOT`。缓冲区只装一条流,旧的那条必然丢失,示例把它计入 `tb->dropped` 并打印告警,而不是无声丢弃: + +``` +[demo_text] a new stream started while 214 bytes of the previous one were still buffered: dropping those — ... +``` + +**`seq` 出现缺口**——`seq` 是回合内的文本包计数器,缺口说明有应用没看到的分片消耗了序号。但这个信号是**有歧义**的:SDK 自己会丢弃零长度文本帧(`tai_protocol.c` 的 `media_text()` 仅在 `payload_len > off` 时上抛),这类帧不携带任何字节,跨过它们拼出来的正是那份正确的文档;只有当缺失的分片属于另一条交错的流时,继续拼接才会把两份文档混在一起——而那种混合物随后会在 JSON 解析处被拒。因此默认策略是**打印告警后继续累积**: + +``` +[demo_text] text seq gap (11 -> 13): continuing — ... +``` + +若某个部署里交错才是更可能的原因,用 `-DDEMO_TEXT_SEQ_CHECK=2` 改为遇缺口即丢流;`-DDEMO_TEXT_SEQ_CHECK=0` 完全关掉该检查。 + +无论哪种丢失,`demo_textbuf_t.dropped` 都会累加(`demo_textbuf_reset()` 不会清零它),示例在退出前据此判定成败——丢了流却报告"本次查询没有音乐响应"并返回 0,会让脚本把丢数据的运行当成成功。 +::: + +另外,`code` 字段要在 SKILL 信封的 `data` 对象里取,而不是在整份文档里取第一个匹配——外层常见的 `{"code":0,"msg":"ok","data":{"code":"music",...}}` 结构会让"取第一个 code"拿到状态码 `0`,从而静默丢弃这条音乐响应。 + +## 公共辅助头文件 + +`examples/posix/ai/rtc-tcp-client/` 下的五个示例共用三个头文件,避免各自复制一份解析代码: + +| 头文件 | 内容 | +|--------|------| +| `demo_json.h` | 极简 JSON 读取(字符串感知的括号配对、`\"` / `\/` / `\uXXXX` 转义解码)、Base64 解码、session token 解析、定长配置字段的有界拷贝 | +| `demo_text.h` | `tai_text_msg_t` 的安全处理:按长度截断的查找、NLG 正文解码打印、文本流重组与丢流记账 | +| `demo_mcp.h` | 设备端 MCP 应答:回显请求 `id`、按 method 返回正确形状;无工具设备用 `demo_mcp_reply_no_tools()` | +| `demo_reconnect.h` | 应用侧重连策略(指数退避 + 熔断器) | + +`demo_json.h` 中的所有函数都要求传入 **以 `\0` 结尾** 的缓冲区;回调里的 `msg->text` / `msg->data` 需要先拷贝。 + ## NLG 文本输出 -非音乐响应的 NLG 文本(AI 的语音回复文字)会流式打印。示例从 JSON 中提取 `content` 字段,仅输出文本内容: +非音乐响应的 NLG 文本(AI 的语音回复文字)会按文本流逐段打印。示例用 `nlg_print_content()` 从 JSON 中提取 `content` 字段,**解码其中的 JSON 转义**后仅输出文本内容——服务端常把中文写成 `\uXXXX`,不解码的话终端上看到的是 `你好` 而不是「你好」: ``` Response: 正在为您播放周杰伦的歌 @@ -245,3 +295,4 @@ AI 音乐功能默认返回的是**试听版**歌曲,存在时长限制(通 - 当前示例仅解析并展示第一首歌曲信息;如需播放完整音频,请在设备端实现音频播放器。 - 元数据展示框按**显示宽度**(中文字符占 2 列)对齐,而非字节数;过长的字段会在字符边界截断。 - `on_audio` 回调在本示例中为空实现,不处理 TTS 音频数据。 +- 本示例声明了 MCP 支持但未实现任何工具,`on_event` 收到 `TAI_EVT_MCP_CMD` 时调用 `demo_mcp.h` 的 `demo_mcp_reply_no_tools()` 作答。注意 **SDK 的内置默认属性本来就打开 MCP**,所以不传 `session_attrs_json` 的设备同样会收到 MCP 请求,必须能正确应答。要实现真正的设备工具请参考 `mcp_demo.c`。 diff --git a/examples/posix/ai/rtc-tcp-client/audio_chat_demo.c b/examples/posix/ai/rtc-tcp-client/audio_chat_demo.c index 72e3b8b..481ecfe 100644 --- a/examples/posix/ai/rtc-tcp-client/audio_chat_demo.c +++ b/examples/posix/ai/rtc-tcp-client/audio_chat_demo.c @@ -23,11 +23,12 @@ #include #include -#include "mbedtls/base64.h" #include #include "tuya_ai.h" #include "iot_client.h" +#include "demo_json.h" +#include "demo_mcp.h" #include "demo_reconnect.h" extern const pal_t *tai_pal_posix(void); @@ -201,9 +202,9 @@ static void on_event(tai_ctx_t *ctx, const tai_event_msg_t *msg, void *ud) dc->audio_end_us = now_us(); dc->got_done = 1; } else if (msg->event_type == TAI_EVT_MCP_CMD) { - tai_send_mcp_response(ctx, - "{\"jsonrpc\":\"2.0\",\"id\":1," - "\"result\":{\"content\":[{\"type\":\"text\",\"text\":\"\"}]}}"); + /* This demo exposes no tools, but it does declare MCP support, so it + * still owes the server a well-formed answer. See demo_mcp.h. */ + demo_mcp_reply_no_tools(ctx, msg); } } @@ -218,169 +219,6 @@ static void on_disconnect(tai_ctx_t *ctx, const tai_disconnect_msg_t *msg, demo_reconnect_signal(&dc->reconn, msg->reason, msg->close_code); } -/* -- Minimal JSON helpers (same as other examples) ---------------------- */ - -static const char *json_find_value(const char *json, const char *key) -{ - if (!json || !key) return NULL; - char search[128]; - snprintf(search, sizeof(search), "\"%s\"", key); - const char *p = strstr(json, search); - if (!p) return NULL; - p += strlen(search); - while (*p == ' ' || *p == ':' || *p == '\t') p++; - return p; -} - -static int json_get_string(const char *json, const char *key, - char *out, size_t cap) -{ - const char *p = json_find_value(json, key); - if (!p || *p != '"') return -1; - p++; - const char *end = strchr(p, '"'); - if (!end) return -1; - size_t len = (size_t)(end - p); - if (len >= cap) len = cap - 1; - memcpy(out, p, len); - out[len] = '\0'; - return 0; -} - -static int json_get_long(const char *json, const char *key, long *out) -{ - const char *p = json_find_value(json, key); - if (!p) return -1; - if (*p != '-' && (*p < '0' || *p > '9')) return -1; - *out = strtol(p, NULL, 10); - return 0; -} - -static int json_array_first_string(const char *json, const char *key, - char *out, size_t cap) -{ - const char *p = json_find_value(json, key); - if (!p || *p != '[') return -1; - p++; - while (*p == ' ') p++; - if (*p != '"') return -1; - p++; - const char *end = strchr(p, '"'); - if (!end) return -1; - size_t len = (size_t)(end - p); - if (len >= cap) len = cap - 1; - memcpy(out, p, len); - out[len] = '\0'; - return 0; -} - -static char *json_get_object(const char *json, const char *key) -{ - const char *p = json_find_value(json, key); - if (!p || *p != '{') return NULL; - int depth = 0; - const char *start = p, *q = p; - while (*q) { - if (*q == '{') depth++; - else if (*q == '}' && --depth == 0) { - size_t len = (size_t)(q - start + 1); - char *obj = (char *)malloc(len + 1); - if (obj) { memcpy(obj, start, len); obj[len] = '\0'; } - return obj; - } - q++; - } - return NULL; -} - -/* -- Base64 decode (OpenSSL) -------------------------------------------- */ - -static char *b64_decode(const char *encoded, size_t *out_len) -{ - size_t elen = strlen(encoded); - size_t max_dlen = (elen * 3) / 4 + 4; - char *out = (char *)malloc(max_dlen + 1); - if (!out) return NULL; - size_t dlen = 0; - if (mbedtls_base64_decode((unsigned char *)out, max_dlen, &dlen, - (const unsigned char *)encoded, elen) != 0) { - free(out); - return NULL; - } - out[dlen] = '\0'; - if (out_len) *out_len = dlen; - return out; -} - -/* -- Parse token -------------------------------------------------------- */ - -typedef struct { - char host[256]; - char tls_sni[256]; - char derived_client_id[256]; - char agent_token[256]; - uint16_t port; - long biz_code; - long biz_tag; -} tai_conn_params_t; - -static int parse_token(const char *raw_token, tai_conn_params_t *p) -{ - memset(p, 0, sizeof(*p)); - char *json = NULL; - { - size_t dl = 0; - char *decoded = b64_decode(raw_token, &dl); - if (decoded && dl > 0 && decoded[0] == '{') - json = decoded; - else { - free(decoded); - json = strdup(raw_token); - } - } - if (!json) return -1; - - char *conn = json_get_object(json, "connect_conf"); - if (!conn) { - fprintf(stderr, "[parse_token] 'connect_conf' not found\n"); - free(json); - return -1; - } - - json_array_first_string(conn, "hosts", p->host, sizeof(p->host)); - if (json_array_first_string(conn, "domains", p->tls_sni, sizeof(p->tls_sni)) != 0) - strncpy(p->tls_sni, p->host, sizeof(p->tls_sni) - 1); - - long port = 0; - if (json_get_long(conn, "ecc_tls_port", &port) != 0) - json_get_long(conn, "tcpport", &port); - p->port = (port > 0) ? (uint16_t)port : 443; - - json_get_string(conn, "derived_client_id", - p->derived_client_id, sizeof(p->derived_client_id)); - free(conn); - - char *sess = json_get_object(json, "session_conf"); - if (sess) { - json_get_string(sess, "agentToken", - p->agent_token, sizeof(p->agent_token)); - char *biz = json_get_object(sess, "bizConfig"); - if (biz) { - json_get_long(biz, "bizCode", &p->biz_code); - json_get_long(biz, "bizTag", &p->biz_tag); - free(biz); - } - free(sess); - } - - free(json); - if (p->host[0] == '\0') { - fprintf(stderr, "[parse_token] Could not extract host\n"); - return -1; - } - return 0; -} - /* -- Opus encode a 16 kHz PCM buffer ------------------------------------ */ typedef struct { @@ -551,9 +389,10 @@ int main(int argc, char *argv[]) .mqtt_disable_tls = false, .message_callback = NULL, }; - memcpy((char *)iot_cfg.devid, devid, strlen(devid)); - memcpy((char *)iot_cfg.secret_key, secret_key, strlen(secret_key)); - memcpy((char *)iot_cfg.local_key, local_key, strlen(local_key)); + if (demo_copy_field((char *)iot_cfg.devid, sizeof(iot_cfg.devid), devid, "devid") != 0 || + demo_copy_field((char *)iot_cfg.secret_key, sizeof(iot_cfg.secret_key), secret_key, "secret_key") != 0 || + demo_copy_field((char *)iot_cfg.local_key, sizeof(iot_cfg.local_key), local_key, "local_key") != 0) + return 1; iot_client_t *iot = iot_client_init(&iot_cfg); if (!iot) { diff --git a/examples/posix/ai/rtc-tcp-client/demo_json.h b/examples/posix/ai/rtc-tcp-client/demo_json.h new file mode 100644 index 0000000..a8ef13e --- /dev/null +++ b/examples/posix/ai/rtc-tcp-client/demo_json.h @@ -0,0 +1,556 @@ +/* + * demo_json.h — shared JSON / base64 / session-token helpers for the + * rtc-tcp-client POSIX demos (header-only). + * + * Deliberately partial — enough to walk a known response shape, with no DOM — + * but string-aware: a '{', '}', '[', ']' or '"' inside a JSON string literal + * never counts as structure, and \" \\ \/ \uXXXX escapes are decoded. Real + * payloads need both (a song title containing a bracket, a server that emits + * "https:\/\/…"). + * + * IMPORTANT: every function here takes a NUL-terminated buffer. Receive-callback + * payloads (tai_text_msg_t.text, tai_event_msg_t.data) are NOT NUL-terminated — + * copy them out first (see demo_text.h). + */ +#ifndef DEMO_JSON_H +#define DEMO_JSON_H + +#include +#include +#include +#include + +#include "mbedtls/base64.h" + +/* -- Scanning primitives -------------------------------------------------- */ + +static inline int json_is_space(char c) +{ + return c == ' ' || c == '\t' || c == '\n' || c == '\r'; +} + +/* `p` points just past a string literal's opening quote. Returns the closing + * quote, honouring backslash escapes, or NULL if the literal is unterminated. */ +static inline const char *json_str_end(const char *p) +{ + while (*p) { + if (*p == '\\') { + if (!p[1]) return NULL; + p += 2; + continue; + } + if (*p == '"') return p; + p++; + } + return NULL; +} + +/* End of the balanced `open`…`close` span starting at `p` — the byte just past + * the closing delimiter — or NULL if it is unbalanced. String literals are + * skipped whole, so a delimiter inside a JSON string never closes the span. */ +static inline const char *json_span_end(const char *p, char open, char close) +{ + if (!p || *p != open) return NULL; + int depth = 0; + while (*p) { + if (*p == '"') { + const char *e = json_str_end(p + 1); + if (!e) return NULL; + p = e + 1; + continue; + } + if (*p == open) { + depth++; + } else if (*p == close && --depth == 0) { + return p + 1; + } + p++; + } + return NULL; +} + +/* Skip one whole JSON value at `p` (string, object, array, number, literal). + * Returns the byte just past it, or NULL if it is malformed. */ +static inline const char *json_skip_value(const char *p) +{ + if (!p) return NULL; + if (*p == '"') { + const char *e = json_str_end(p + 1); + return e ? e + 1 : NULL; + } + if (*p == '{') return json_span_end(p, '{', '}'); + if (*p == '[') return json_span_end(p, '[', ']'); + + const char *s = p; /* number / true / false / null */ + while (*p && *p != ',' && *p != '}' && *p != ']' && !json_is_space(*p)) p++; + return (p > s) ? p : NULL; +} + +/* Value that follows the first `"key":` in `json`, at any depth; NULL if + * absent. String literals are skipped whole, so a key name inside a *value* is + * not mistaken for the key. The match is first-in-document-order: for + * {"code":0,"data":{"code":"music"}} this returns the outer 0 — drill into the + * enclosing object (json_get_object) when you want a nested key, or use + * json_object_find() when only a top-level member will do. */ +static inline const char *json_find_value(const char *json, const char *key) +{ + if (!json || !key) return NULL; + size_t klen = strlen(key); + const char *p = json; + while (*p) { + if (*p != '"') { p++; continue; } + const char *s = p + 1; + const char *e = json_str_end(s); + if (!e) return NULL; + if ((size_t)(e - s) == klen && memcmp(s, key, klen) == 0) { + const char *v = e + 1; + while (json_is_space(*v)) v++; + if (*v == ':') { + v++; + while (json_is_space(*v)) v++; + return v; + } + } + p = e + 1; /* not our key (or not used as a key): skip the literal */ + } + return NULL; +} + +/* Value of `key` among the TOP-LEVEL members of the object at `json` (leading + * whitespace then '{'); NULL if absent or if the text is not an object. + * + * Unlike json_find_value(), a same-named key nested inside another member's + * value is never returned. Protocol fields need this: a JSON-RPC request may + * legitimately carry an `id` or `method` of its own inside params.arguments, + * and echoing that one back breaks request/response correlation. */ +static inline const char *json_object_find(const char *json, const char *key) +{ + if (!json || !key) return NULL; + const char *p = json; + while (json_is_space(*p)) p++; + if (*p != '{') return NULL; + p++; + + size_t klen = strlen(key); + for (;;) { + while (json_is_space(*p)) p++; + if (*p != '"') return NULL; /* '}' , '\0' or malformed */ + + const char *s = p + 1; + const char *e = json_str_end(s); + if (!e) return NULL; + int match = ((size_t)(e - s) == klen && memcmp(s, key, klen) == 0); + + p = e + 1; + while (json_is_space(*p)) p++; + if (*p != ':') return NULL; + p++; + while (json_is_space(*p)) p++; + if (match) return p; + + p = json_skip_value(p); + if (!p) return NULL; + while (json_is_space(*p)) p++; + if (*p != ',') return NULL; /* '}' or malformed */ + p++; + } +} + +/* -- String values -------------------------------------------------------- */ + +static inline int json_hex4(const char *p, uint32_t *out) +{ + uint32_t v = 0; + for (int i = 0; i < 4; i++) { + char c = p[i]; + v <<= 4; + if (c >= '0' && c <= '9') v |= (uint32_t)(c - '0'); + else if (c >= 'a' && c <= 'f') v |= (uint32_t)(c - 'a' + 10); + else if (c >= 'A' && c <= 'F') v |= (uint32_t)(c - 'A' + 10); + else return -1; + } + *out = v; + return 0; +} + +static inline int json_utf8_put(char *out, size_t cap, size_t *n, uint32_t cp) +{ + char tmp[4]; + size_t k = 0; + if (cp < 0x80) { + tmp[k++] = (char)cp; + } else if (cp < 0x800) { + tmp[k++] = (char)(0xC0 | (cp >> 6)); + tmp[k++] = (char)(0x80 | (cp & 0x3F)); + } else if (cp < 0x10000) { + tmp[k++] = (char)(0xE0 | (cp >> 12)); + tmp[k++] = (char)(0x80 | ((cp >> 6) & 0x3F)); + tmp[k++] = (char)(0x80 | (cp & 0x3F)); + } else { + tmp[k++] = (char)(0xF0 | (cp >> 18)); + tmp[k++] = (char)(0x80 | ((cp >> 12) & 0x3F)); + tmp[k++] = (char)(0x80 | ((cp >> 6) & 0x3F)); + tmp[k++] = (char)(0x80 | (cp & 0x3F)); + } + if (*n + k >= cap) return -1; + memcpy(out + *n, tmp, k); + *n += k; + return 0; +} + +/* Decode the raw body of a JSON string literal — `p`/`len` delimit the bytes + * BETWEEN the quotes, so this works on a slice that carries no terminator — + * into NUL-terminated `out`. Returns the decoded length, or -1 if the text is + * malformed. + * + * `truncate` decides what "does not fit" means. 0: empty `out` and return -1, + * so ignoring the return value yields an obviously empty field rather than a + * half-copied credential. 1: keep the prefix that fits and return its length — + * the rest is still decoded, so a malformed tail is still reported. */ +static inline int json_unescape_ex(const char *p, size_t len, char *out, + size_t cap, int truncate) +{ + if (!out || cap == 0) return -1; + out[0] = '\0'; + if (!p) return len ? -1 : 0; + + const char *end = p + len; + size_t n = 0; + int full = 0; /* out is up to capacity; keep validating only */ + + while (p < end) { + char lit = 0; + uint32_t cp = 0; + int is_cp = 0; + + if (*p != '\\') { + lit = *p++; + } else { + if (++p >= end) goto fail; + switch (*p) { + case '"': case '\\': case '/': lit = *p++; break; + case 'b': lit = '\b'; p++; break; + case 'f': lit = '\f'; p++; break; + case 'n': lit = '\n'; p++; break; + case 'r': lit = '\r'; p++; break; + case 't': lit = '\t'; p++; break; + case 'u': + if (end - p < 5 || json_hex4(p + 1, &cp) != 0) goto fail; + p += 5; + if (cp >= 0xD800 && cp <= 0xDBFF && end - p >= 6 && + p[0] == '\\' && p[1] == 'u') { + uint32_t lo = 0; + if (json_hex4(p + 2, &lo) == 0 && lo >= 0xDC00 && lo <= 0xDFFF) { + cp = 0x10000u + ((cp - 0xD800u) << 10) + (lo - 0xDC00u); + p += 6; + } + } + /* An embedded NUL truncates the value for every strlen + * consumer, and a lone surrogate is not a character. */ + if (cp == 0 || (cp >= 0xD800 && cp <= 0xDFFF)) goto fail; + is_cp = 1; + break; + default: + goto fail; + } + } + + if (full) continue; + if (is_cp) { + if (json_utf8_put(out, cap, &n, cp) != 0) { + if (!truncate) goto fail; + full = 1; + } + } else if (n + 1 >= cap) { + if (!truncate) goto fail; + full = 1; + } else { + out[n++] = lit; + } + } + out[n] = '\0'; + return (int)n; + +fail: + out[0] = '\0'; + return -1; +} + +/* Decode the raw body of a string literal, rejecting a value that does not fit + * (see json_unescape_ex). Returns the decoded length, or -1. */ +static inline int json_unescape(const char *p, size_t len, char *out, size_t cap) +{ + return json_unescape_ex(p, len, out, cap, 0); +} + +/* Locate the string literal at `p` (its opening quote) and hand its raw body + * back as `body`/`body_len`. Returns 0, or -1 if `p` is not a terminated + * string literal. */ +static inline int json_string_body(const char *p, const char **body, + size_t *body_len) +{ + if (!p || *p != '"') return -1; + const char *e = json_str_end(p + 1); + if (!e) return -1; + *body = p + 1; + *body_len = (size_t)(e - p - 1); + return 0; +} + +/* Decode the string literal at `p` (its opening quote) into `out`. Returns 0, + * or -1 if it is malformed or does not fit — `out` is emptied rather than + * truncated, so ignoring the return value yields an obviously empty field. */ +static inline int json_copy_string(const char *p, char *out, size_t cap) +{ + if (!out || cap == 0) return -1; + out[0] = '\0'; + + const char *body; + size_t body_len; + if (json_string_body(p, &body, &body_len) != 0) return -1; + return json_unescape(body, body_len, out, cap) < 0 ? -1 : 0; +} + +static inline int json_get_string(const char *json, const char *key, + char *out, size_t cap) +{ + return json_copy_string(json_find_value(json, key), out, cap); +} + +/* json_get_string() restricted to a top-level member (see json_object_find). */ +static inline int json_object_get_string(const char *json, const char *key, + char *out, size_t cap) +{ + return json_copy_string(json_object_find(json, key), out, cap); +} + +/* Like json_get_string(), but for a value that is only ever printed: one too + * long for `out` is truncated rather than dropped, since a shortened song title + * still tells the reader what was found where a shortened credential does not. + * Returns 0 when `out` holds something, -1 when the key is absent, is not a + * string, or is malformed. */ +static inline int json_get_display_string(const char *json, const char *key, + char *out, size_t cap) +{ + if (!out || cap == 0) return -1; + out[0] = '\0'; + + const char *body; + size_t body_len; + if (json_string_body(json_find_value(json, key), &body, &body_len) != 0) + return -1; + return json_unescape_ex(body, body_len, out, cap, 1) < 0 ? -1 : 0; +} + +/* json_get_string() empties the field on ANY failure, so the caller cannot tell + * capacity from a wrong type or a bad escape. Print which it was; silent when + * the key is simply absent. */ +static inline void json_explain_string_failure(const char *who, const char *json, + const char *key, size_t cap) +{ + const char *p = json_find_value(json, key); + if (!p) return; + + const char *body; + size_t body_len; + if (json_string_body(p, &body, &body_len) != 0) { + fprintf(stderr, "[%s] \"%s\" is not a terminated string\n", who, key); + return; + } + /* A truncating decode fails only on malformed text, never on capacity. */ + char probe[1]; + if (json_unescape_ex(body, body_len, probe, sizeof(probe), 1) < 0) + fprintf(stderr, "[%s] \"%s\" contains a malformed escape\n", who, key); + else + fprintf(stderr, "[%s] \"%s\" does not fit (%zu raw bytes into a " + "%zu-byte field)\n", who, key, body_len, cap - 1); +} + +/* -- Number values -------------------------------------------------------- */ + +static inline int json_get_long(const char *json, const char *key, long *out) +{ + const char *p = json_find_value(json, key); + if (!p) return -1; + if (*p != '-' && (*p < '0' || *p > '9')) return -1; + *out = strtol(p, NULL, 10); + return 0; +} + +/* -- Container values ----------------------------------------------------- */ + +/* Copy the balanced `open`…`close` span starting at `p` into a fresh + * NUL-terminated buffer (caller frees). String literals are skipped whole, so + * a delimiter inside a JSON string never closes the span. */ +static inline char *json_copy_span(const char *p, char open, char close) +{ + const char *e = json_span_end(p, open, close); + if (!e) return NULL; + size_t len = (size_t)(e - p); + char *dup = (char *)malloc(len + 1); + if (dup) { memcpy(dup, p, len); dup[len] = '\0'; } + return dup; +} + +/* The `key` object / array as a fresh NUL-terminated buffer (caller frees). */ +static inline char *json_get_object(const char *json, const char *key) +{ + return json_copy_span(json_find_value(json, key), '{', '}'); +} + +static inline char *json_get_array(const char *json, const char *key) +{ + return json_copy_span(json_find_value(json, key), '[', ']'); +} + +/* json_get_object() restricted to a top-level member (see json_object_find). */ +static inline char *json_object_get_object(const char *json, const char *key) +{ + return json_copy_span(json_object_find(json, key), '{', '}'); +} + +/* First element of the `key` array, when that element is a string. */ +static inline int json_array_first_string(const char *json, const char *key, + char *out, size_t cap) +{ + const char *p = json_find_value(json, key); + if (!p || *p != '[') { if (out && cap) out[0] = '\0'; return -1; } + p++; + while (json_is_space(*p)) p++; + return json_copy_string(p, out, cap); +} + +/* First element of the array text `arr` ("[{…},…]"), when that element is an + * object. Returns a fresh NUL-terminated buffer (caller frees). */ +static inline char *json_array_first_object(const char *arr) +{ + if (!arr || *arr != '[') return NULL; + const char *p = arr + 1; + while (json_is_space(*p)) p++; + return json_copy_span(p, '{', '}'); +} + +/* -- Base64 --------------------------------------------------------------- */ + +static inline char *b64_decode(const char *encoded, size_t *out_len) +{ + size_t elen = strlen(encoded); + size_t dlen = 0; + if (mbedtls_base64_decode(NULL, 0, &dlen, + (const unsigned char *)encoded, elen) + != MBEDTLS_ERR_BASE64_BUFFER_TOO_SMALL) + return NULL; + char *out = (char *)malloc(dlen + 1); + if (!out) return NULL; + if (mbedtls_base64_decode((unsigned char *)out, dlen, &dlen, + (const unsigned char *)encoded, elen) != 0) { + free(out); + return NULL; + } + out[dlen] = '\0'; + if (out_len) *out_len = dlen; + return out; +} + +/* -- Fixed-size config fields ---------------------------------------------- */ + +/* Bounded copy into a fixed-size field (e.g. iot_client_config_t's char[32] + * credentials): a value that does not fit is rejected, not truncated. */ +static inline int demo_copy_field(char *dst, size_t cap, const char *src, + const char *what) +{ + size_t n = strlen(src); + if (n >= cap) { + fprintf(stderr, "%s is too long (%zu bytes; max %zu)\n", what, n, cap - 1); + return -1; + } + memcpy(dst, src, n); + dst[n] = '\0'; + return 0; +} + +/* -- Session token -> TAI connection params ------------------------------- */ + +typedef struct { + char host[256]; + char tls_sni[256]; + char derived_client_id[256]; + char agent_token[256]; + uint16_t port; + long biz_code; + long biz_tag; +} tai_conn_params_t; + +/* The session token from iot_client_get_session_token() is base64-wrapped JSON + * (some deployments hand back the JSON directly). Pull out the connect address + * and the session config. */ +static inline int parse_token(const char *raw_token, tai_conn_params_t *p) +{ + memset(p, 0, sizeof(*p)); + + char *json = NULL; + { + size_t dl = 0; + char *decoded = b64_decode(raw_token, &dl); + if (decoded && dl > 0 && decoded[0] == '{') { + json = decoded; + } else { + free(decoded); + json = strdup(raw_token); + } + } + if (!json) return -1; + + char *conn = json_get_object(json, "connect_conf"); + if (!conn) { + fprintf(stderr, "[parse_token] 'connect_conf' not found\n"); + free(json); + return -1; + } + + json_array_first_string(conn, "hosts", p->host, sizeof(p->host)); + if (json_array_first_string(conn, "domains", p->tls_sni, sizeof(p->tls_sni)) != 0) + strncpy(p->tls_sni, p->host, sizeof(p->tls_sni) - 1); + + long port = 0; + if (json_get_long(conn, "ecc_tls_port", &port) != 0) + json_get_long(conn, "tcpport", &port); + if (port > 65535) { + fprintf(stderr, "[parse_token] port %ld out of range; using 443\n", port); + port = 0; + } + p->port = (port > 0) ? (uint16_t)port : 443; + + /* json_get_string leaves the field EMPTY on any failure; connecting with an + * empty client id / token fails with nothing pointing back at the token, so + * name the actual cause here — capacity, wrong type, or a bad escape. */ + if (json_get_string(conn, "derived_client_id", + p->derived_client_id, sizeof(p->derived_client_id)) != 0) + json_explain_string_failure("parse_token", conn, "derived_client_id", + sizeof(p->derived_client_id)); + free(conn); + + char *sess = json_get_object(json, "session_conf"); + if (sess) { + if (json_get_string(sess, "agentToken", + p->agent_token, sizeof(p->agent_token)) != 0) + json_explain_string_failure("parse_token", sess, "agentToken", + sizeof(p->agent_token)); + char *biz = json_get_object(sess, "bizConfig"); + if (biz) { + json_get_long(biz, "bizCode", &p->biz_code); + json_get_long(biz, "bizTag", &p->biz_tag); + free(biz); + } + free(sess); + } + free(json); + + if (p->host[0] == '\0') { + fprintf(stderr, "[parse_token] Could not extract host\n"); + return -1; + } + return 0; +} + +#endif /* DEMO_JSON_H */ diff --git a/examples/posix/ai/rtc-tcp-client/demo_mcp.h b/examples/posix/ai/rtc-tcp-client/demo_mcp.h new file mode 100644 index 0000000..d562026 --- /dev/null +++ b/examples/posix/ai/rtc-tcp-client/demo_mcp.h @@ -0,0 +1,139 @@ +/* + * demo_mcp.h — device-side MCP request handling shared by the rtc-tcp-client + * POSIX demos (header-only). + * + * The server pushes MCP JSON-RPC 2.0 requests as TAI_EVT_MCP_CMD events to any + * device declaring deviceMcp.supportCustomMCP — which the SDK's default + * session attributes do, so passing no session_attrs_json opts you in. + * + * Answering properly, even with an empty tool catalog, means: + * + * - echo the request `id` — JSON-RPC correlates on it; + * - use the result shape the method expects (an `initialize` handshake + * answered with a `tools/call` body is not an answer); + * - stay silent for an id-less request, which is a notification. + * + * demo_mcp_reply_no_tools() does that. mcp_demo.c shows the real thing: a tool + * registry, dispatch and results. + */ +#ifndef DEMO_MCP_H +#define DEMO_MCP_H + +#include +#include +#include + +#include "tuya_ai.h" +#include "demo_json.h" + +#ifndef DEMO_MCP_PROTOCOL_VERSION +#define DEMO_MCP_PROTOCOL_VERSION "2024-11-05" +#endif +#ifndef DEMO_MCP_SERVER_NAME +#define DEMO_MCP_SERVER_NAME "tuya-agentic-kit-demo" +#endif +#ifndef DEMO_MCP_SERVER_VERSION +#define DEMO_MCP_SERVER_VERSION "1.0.0" +#endif + +/* Copy the request's `id` token verbatim (a string, a number or null, kept as + * written so it can be spliced straight back into the response). + * + * The id is read from the TOP-LEVEL members only. A `tools/call` may carry an + * "id" of its own inside params.arguments — a lamp id, a track id — and a + * document-order search finds that one first, which would echo the wrong value + * and leave the server unable to match the response to its request. + * + * Returns 0 on success; -1 when the request carries no id — a notification, + * which takes no response — and when the token cannot be echoed verbatim: + * a truncated quoted id, or an object/array id (not a JSON-RPC id type, and + * splicing part of one back would produce unbalanced JSON). `out` is untouched + * on -1, so a caller may pre-seed it with a default. */ +static inline int demo_mcp_copy_id(const char *request, char *out, size_t cap) +{ + const char *p = json_object_find(request, "id"); + if (!p || cap == 0) return -1; + + size_t n; + if (*p == '"') { + const char *end = json_str_end(p + 1); /* escape-aware */ + if (!end) return -1; + n = (size_t)(end - p + 1); + } else { + const char *end = json_skip_value(p); + if (!end || *p == '{' || *p == '[') return -1; + n = (size_t)(end - p); + /* JSON-RPC 2.0 ids are String, Number or Null; anything else that got + * this far is not one, so do not quote it back at the server. */ + if (*p != '-' && (*p < '0' || *p > '9') && + !(n == 4 && memcmp(p, "null", 4) == 0)) return -1; + } + if (n == 0 || n >= cap) return -1; + + memcpy(out, p, n); + out[n] = '\0'; + return 0; +} + +/* Answer one TAI_EVT_MCP_CMD event as a device that exposes no tools. Call it + * straight from on_event with the borrowed msg — the payload is copied before + * parsing, since tai_event_msg_t.data is not NUL-terminated. */ +static inline void demo_mcp_reply_no_tools(tai_ctx_t *ctx, + const tai_event_msg_t *msg) +{ + if (!msg->data || msg->len == 0) return; + + char *req = (char *)malloc(msg->len + 1); + if (!req) { + fprintf(stderr, "[demo_mcp] out of memory copying a %zu-byte request\n", + msg->len); + return; + } + memcpy(req, msg->data, msg->len); + req[msg->len] = '\0'; + + char id[64]; + char method[64] = {0}; + int have_id = (demo_mcp_copy_id(req, id, sizeof(id)) == 0); + /* Top-level only, for the same reason as the id: params.arguments may hold + * a "method" of its own, and answering that one is a wrong answer. */ + json_object_get_string(req, "method", method, sizeof(method)); + free(req); + + if (!have_id) return; /* notification (or an unusable id): no response */ + + char resp[512]; + int n; + if (strcmp(method, "initialize") == 0) { + n = snprintf(resp, sizeof(resp), + "{\"jsonrpc\":\"2.0\",\"id\":%s,\"result\":{" + "\"protocolVersion\":\"%s\"," + "\"serverInfo\":{\"name\":\"%s\",\"version\":\"%s\"}," + "\"capabilities\":{\"tools\":{}}}}", + id, DEMO_MCP_PROTOCOL_VERSION, + DEMO_MCP_SERVER_NAME, DEMO_MCP_SERVER_VERSION); + } else if (strcmp(method, "tools/list") == 0) { + n = snprintf(resp, sizeof(resp), + "{\"jsonrpc\":\"2.0\",\"id\":%s,\"result\":{\"tools\":[]}}", id); + } else if (strcmp(method, "tools/call") == 0) { + /* The advertised catalog is empty, so every tool name is invalid. */ + n = snprintf(resp, sizeof(resp), + "{\"jsonrpc\":\"2.0\",\"id\":%s,\"error\":" + "{\"code\":-32602,\"message\":\"this device exposes no tools\"}}", id); + } else { + n = snprintf(resp, sizeof(resp), + "{\"jsonrpc\":\"2.0\",\"id\":%s,\"error\":" + "{\"code\":-32601,\"message\":\"Method not found\"}}", id); + } + + if (n < 0 || (size_t)n >= sizeof(resp)) { + fprintf(stderr, "[demo_mcp] response overflow (method=\"%s\")\n", method); + return; + } + + int rc = tai_send_mcp_response(ctx, resp); + if (rc != TAI_OK) + fprintf(stderr, "[demo_mcp] tai_send_mcp_response failed: %d\n", rc); +} + +#endif /* DEMO_MCP_H */ diff --git a/examples/posix/ai/rtc-tcp-client/demo_text.h b/examples/posix/ai/rtc-tcp-client/demo_text.h new file mode 100644 index 0000000..986b110 --- /dev/null +++ b/examples/posix/ai/rtc-tcp-client/demo_text.h @@ -0,0 +1,298 @@ +/* + * demo_text.h — safe handling of tai_text_msg_t for the rtc-tcp-client POSIX + * demos (header-only). + * + * Two hazards it removes: + * + * 1. tai_text_msg_t.text is a borrowed slice of the SDK receive buffer and is + * NOT NUL-terminated (see tuya_ai.h), so strstr/strchr on it run past + * msg->len and eventually past the end of the tai_ctx_t allocation. + * Everything here is length-bounded, or copies the bytes out first. + * + * 2. Text arrives chunked — TAI_STREAM_START / MIDDLE / END, or a single + * ONE_SHOT. Printing can take each chunk as it comes; parsing JSON must + * reassemble first, since the fields may straddle a chunk boundary. + * + * The accumulator holds ONE stream. It cannot demux two that interleave inside + * a turn — they share event_id, and data_id is the constant + * TAI_DATA_ID_TEXT_DOWN — so every loss it detects is reported rather than + * papered over: tb->dropped counts the streams it had to give up on, and a + * caller that exits with a status should consult it. + */ +#ifndef DEMO_TEXT_H +#define DEMO_TEXT_H + +#include +#include +#include +#include + +#include "tuya_ai.h" +#include "demo_json.h" + +/* Ceiling on a reassembled text stream; a server that never sends END cannot + * grow the buffer without bound. */ +#ifndef DEMO_TEXTBUF_MAX +#define DEMO_TEXTBUF_MAX (256u * 1024u) +#endif + +/* Largest NLG chunk nlg_print_content() decodes; a longer one is printed with + * its escapes still in place rather than dropped. This is a stack buffer in the + * receive callback, so shrink it when porting to a thread with a small stack — + * one NLG chunk is a fragment of a sentence, not a whole reply. */ +#ifndef DEMO_NLG_CHUNK_MAX +#define DEMO_NLG_CHUNK_MAX 2048 +#endif + +/* What to do when a chunk's seq does not follow the previous one: + * + * 0 — no check. + * 1 — warn and keep accumulating (default). + * 2 — drop the stream. + * + * A gap is ambiguous. It does mean chunks the app never saw consumed a seq, but + * the SDK itself swallows zero-length text frames (tai_protocol.c media_text + * only emits when payload_len > off), and those carry no bytes: continuing past + * a gap they caused reassembles exactly the right document, while dropping + * loses a healthy stream. Only when the missing chunks belonged to a second, + * interleaved stream does continuing splice two documents together — and the + * JSON parse then rejects the mixture anyway. Build with + * -DDEMO_TEXT_SEQ_CHECK=2 for a deployment where interleaving is the likelier + * cause. */ +#ifndef DEMO_TEXT_SEQ_CHECK +#define DEMO_TEXT_SEQ_CHECK 1 +#endif + +/* -- Bounded search ------------------------------------------------------- */ + +/* strstr() over a slice that carries no NUL terminator. */ +static inline const char *demo_memfind(const char *hay, size_t hlen, + const char *needle) +{ + size_t nlen = strlen(needle); + if (nlen == 0 || hlen < nlen) return NULL; + for (size_t i = 0; i + nlen <= hlen; i++) { + if (hay[i] == needle[0] && memcmp(hay + i, needle, nlen) == 0) + return hay + i; + } + return NULL; +} + +/* -- NLG prose ------------------------------------------------------------ */ + +/* Point at the raw (still JSON-escaped) content field of an NLG line. Reads + * only within [text, text+len), so it is safe on the callback slice. NULL if + * this chunk is not NLG, or the value does not terminate inside it. */ +static inline const char *nlg_extract_content(const char *text, size_t len, + size_t *out_len) +{ + if (!text || len == 0 || !out_len) return NULL; + if (!demo_memfind(text, len, "\"NLG\"")) return NULL; + + const char *end = text + len; + const char *p = demo_memfind(text, len, "\"content\""); + if (!p) return NULL; + p += sizeof("\"content\"") - 1; + + while (p < end && json_is_space(*p)) p++; + if (p >= end || *p != ':') return NULL; + p++; + while (p < end && json_is_space(*p)) p++; + if (p >= end || *p != '"') return NULL; + p++; + + for (const char *q = p; q < end; ) { + if (*q == '\\') { q += 2; continue; } + if (*q == '"') { *out_len = (size_t)(q - p); return p; } + q++; + } + return NULL; +} + +/* Print one chunk's NLG prose with its JSON escapes decoded — \n, \", and the + * \uXXXX the server uses for CJK all reach the terminal as the characters they + * stand for, which the raw slice from nlg_extract_content() does not. + * + * Returns 1 if this chunk was NLG and has been handled, so the caller must not + * also print it raw. That includes the empty terminator line ({"content":""}), + * which prints nothing — it is still NLG. Returns 0 otherwise. */ +static inline int nlg_print_content(const char *text, size_t len) +{ + size_t raw_len = 0; + const char *raw = nlg_extract_content(text, len, &raw_len); + if (!raw) return 0; + + char buf[DEMO_NLG_CHUNK_MAX]; + int n = json_unescape(raw, raw_len, buf, sizeof(buf)); + if (n < 0) + fwrite(raw, 1, raw_len, stdout); /* too long, or a bad escape */ + else if (n > 0) + fwrite(buf, 1, (size_t)n, stdout); + fflush(stdout); + return 1; +} + +/* -- Stream reassembly ---------------------------------------------------- */ + +typedef struct { + char *buf; /* NUL-terminated once accum()/flush() returns 1 */ + size_t len; + size_t cap; + uint32_t seq; /* seq of the last chunk taken in */ + int have_seq; /* seq is meaningful (a stream is in progress) */ + int done; /* buf holds an already-delivered stream */ + int dropping; /* current stream was dropped; swallow it through END */ + unsigned dropped; /* streams lost so far — survives reset(); see below */ +} demo_textbuf_t; + +/* Discard any buffered or delivered stream, keeping the allocation. Call when + * the connection drops: a half-received stream from the old connection must + * not prefix the first stream of the new one. + * + * tb->dropped is deliberately NOT cleared: it is the session's tally of lost + * streams, and a caller that reports success or failure needs it to survive + * every reset in between. */ +static inline void demo_textbuf_reset(demo_textbuf_t *tb) +{ + tb->len = 0; + tb->have_seq = 0; + tb->done = 0; + tb->dropping = 0; +} + +static inline void demo_textbuf_free(demo_textbuf_t *tb) +{ + free(tb->buf); + tb->buf = NULL; + tb->cap = 0; + demo_textbuf_reset(tb); +} + +/* Give up on the stream being accumulated: name the reason once, count it, and + * swallow its remaining chunks so the caller sees one -1 per stream rather than + * one per chunk. The allocation is kept — reset(), not free() — since the next + * stream would otherwise have to grow it from 1 KB all over again. */ +static inline int demo_textbuf_drop(demo_textbuf_t *tb, int ends, const char *why) +{ + fprintf(stderr, "[demo_text] dropping a text stream: %s\n", why); + tb->dropped++; + demo_textbuf_reset(tb); + tb->dropping = !ends; + return -1; +} + +/* Append one chunk. START and ONE_SHOT begin a fresh stream; so does any chunk + * after a delivered one, so a duplicated END cannot re-deliver it and a + * lost-START continuation cannot extend it. + * + * Returns 1 when the stream has ended and tb->buf holds the whole text, + * NUL-terminated; 0 while more chunks are expected (or the stream was empty); + * -1 when it had to be dropped — too large, out of memory, or (under + * DEMO_TEXT_SEQ_CHECK=2) its chunks did not arrive consecutively. + * + * A stream displaced by a new START is also lost, but the displacing stream is + * accumulated normally and that chunk still returns 0 or 1. Every loss, however + * it is reported, increments tb->dropped — check that, not the return value, to + * decide whether a session lost data. */ +static inline int demo_textbuf_accum(demo_textbuf_t *tb, const tai_text_msg_t *msg) +{ + int starts = (msg->stream_flag == TAI_STREAM_START || + msg->stream_flag == TAI_STREAM_ONE_SHOT); + int ends = (msg->stream_flag == TAI_STREAM_END || + msg->stream_flag == TAI_STREAM_ONE_SHOT); + + /* A fresh stream displaces whatever is still buffered. When that was a + * stream in progress it is gone — say so. This is how an interleaved + * ONE_SHOT or START used to vanish silently: the reset below runs before + * the seq check ever sees the gap. */ + if (starts && !tb->done && !tb->dropping && tb->len > 0) { + fprintf(stderr, + "[demo_text] a new stream started while %zu bytes of the " + "previous one were still buffered: dropping those — this " + "buffer holds one stream and cannot demux interleaved ones\n", + tb->len); + tb->dropped++; + } + + if (starts || tb->done) + demo_textbuf_reset(tb); + + if (tb->dropping) { + if (ends) tb->dropping = 0; + return 0; + } + +#if DEMO_TEXT_SEQ_CHECK + /* Chunks the app never saw consumed a seq: either the SDK swallowed empty + * frames, or another stream's chunks landed in between. See + * DEMO_TEXT_SEQ_CHECK above for why the default is to continue. */ + if (!starts && tb->have_seq && msg->seq != tb->seq + 1) { +#if DEMO_TEXT_SEQ_CHECK >= 2 + char why[128]; + snprintf(why, sizeof(why), + "seq gap (%u -> %u), so its chunks did not arrive consecutively", + (unsigned)tb->seq, (unsigned)msg->seq); + return demo_textbuf_drop(tb, ends, why); +#else + fprintf(stderr, + "\n[demo_text] text seq gap (%u -> %u): continuing — chunks the " + "SDK dropped for being empty look exactly like this. Build with " + "-DDEMO_TEXT_SEQ_CHECK=2 to drop the stream instead\n", + (unsigned)tb->seq, (unsigned)msg->seq); +#endif + } +#endif + tb->seq = msg->seq; + tb->have_seq = 1; + + if (msg->len > 0) { + size_t need = tb->len + msg->len + 1; + if (need > DEMO_TEXTBUF_MAX) { + char why[128]; + snprintf(why, sizeof(why), + "it reached %zu bytes, past the %u-byte DEMO_TEXTBUF_MAX", + need, (unsigned)DEMO_TEXTBUF_MAX); + return demo_textbuf_drop(tb, ends, why); + } + if (need > tb->cap) { + size_t cap = tb->cap ? tb->cap : 1024; + while (cap < need) cap *= 2; + char *nb = (char *)realloc(tb->buf, cap); + if (!nb) { + /* realloc left the old block intact, but the heap is tight: + * hand it back rather than hold it for the next stream. */ + fprintf(stderr, "[demo_text] dropping a text stream: out of " + "memory growing the buffer to %zu bytes\n", cap); + tb->dropped++; + demo_textbuf_free(tb); + tb->dropping = !ends; + return -1; + } + tb->buf = nb; + tb->cap = cap; + } + memcpy(tb->buf + tb->len, msg->text, msg->len); + tb->len += msg->len; + } + + if (!ends || tb->len == 0) return 0; + + tb->buf[tb->len] = '\0'; + tb->done = 1; + return 1; +} + +/* Deliver whatever is buffered even though no END chunk arrived: the SDK drops + * empty text frames (tai_protocol.c media_text), so a stream ended by a bare + * zero-length END never completes through accum(). Call at TAI_EVT_END. + * Returns 1 with tb->buf NUL-terminated if a stream was pending, else 0. */ +static inline int demo_textbuf_flush(demo_textbuf_t *tb) +{ + tb->dropping = 0; + if (tb->done || tb->len == 0) return 0; + tb->buf[tb->len] = '\0'; + tb->done = 1; + return 1; +} + +#endif /* DEMO_TEXT_H */ diff --git a/examples/posix/ai/rtc-tcp-client/edu_camera_demo.c b/examples/posix/ai/rtc-tcp-client/edu_camera_demo.c index cb57a43..b29e59b 100644 --- a/examples/posix/ai/rtc-tcp-client/edu_camera_demo.c +++ b/examples/posix/ai/rtc-tcp-client/edu_camera_demo.c @@ -31,10 +31,10 @@ #include #include -#include "mbedtls/base64.h" - #include "tuya_ai.h" #include "iot_client.h" +#include "demo_json.h" +#include "demo_mcp.h" #include "demo_reconnect.h" extern const pal_t *tai_pal_posix(void); @@ -120,10 +120,10 @@ static void on_event(tai_ctx_t *ctx, dc->audio_end_us = now_us(); dc->got_done = 1; } else if (msg->event_type == TAI_EVT_MCP_CMD) { - const char *empty = - "{\"jsonrpc\":\"2.0\",\"id\":1," - "\"result\":{\"content\":[{\"type\":\"text\",\"text\":\"\"}]}}"; - tai_send_mcp_response(ctx, empty); + /* This demo exposes no tools, but the SDK's default session attributes + * declare MCP support, so it still owes the server a well-formed + * answer. See demo_mcp.h. */ + demo_mcp_reply_no_tools(ctx, msg); } } @@ -149,174 +149,6 @@ static uint8_t detect_image_format(const uint8_t *data, size_t len) return TAI_IMG_JPEG; } -/* -- Minimal JSON helpers (same pattern as iot_chat) --------------------- */ - -static const char *json_find_value(const char *json, const char *key) -{ - if (!json || !key) return NULL; - char search[128]; - snprintf(search, sizeof(search), "\"%s\"", key); - const char *p = strstr(json, search); - if (!p) return NULL; - p += strlen(search); - while (*p == ' ' || *p == ':' || *p == '\t') p++; - return p; -} - -static int json_get_string(const char *json, const char *key, - char *out, size_t cap) -{ - const char *p = json_find_value(json, key); - if (!p || *p != '"') return -1; - p++; - const char *end = strchr(p, '"'); - if (!end) return -1; - size_t len = (size_t)(end - p); - if (len >= cap) len = cap - 1; - memcpy(out, p, len); - out[len] = '\0'; - return 0; -} - -static int json_get_long(const char *json, const char *key, long *out) -{ - const char *p = json_find_value(json, key); - if (!p) return -1; - if (*p != '-' && (*p < '0' || *p > '9')) return -1; - *out = strtol(p, NULL, 10); - return 0; -} - -static int json_array_first_string(const char *json, const char *key, - char *out, size_t cap) -{ - const char *p = json_find_value(json, key); - if (!p || *p != '[') return -1; - p++; - while (*p == ' ') p++; - if (*p != '"') return -1; - p++; - const char *end = strchr(p, '"'); - if (!end) return -1; - size_t len = (size_t)(end - p); - if (len >= cap) len = cap - 1; - memcpy(out, p, len); - out[len] = '\0'; - return 0; -} - -static char *json_get_object(const char *json, const char *key) -{ - const char *p = json_find_value(json, key); - if (!p || *p != '{') return NULL; - int depth = 0; - const char *start = p, *q = p; - while (*q) { - if (*q == '{') depth++; - else if (*q == '}' && --depth == 0) { - size_t len = (size_t)(q - start + 1); - char *obj = (char *)malloc(len + 1); - if (obj) { memcpy(obj, start, len); obj[len] = '\0'; } - return obj; - } - q++; - } - return NULL; -} - -/* -- Base64 decode (OpenSSL EVP) ---------------------------------------- */ - -static char *b64_decode(const char *encoded, size_t *out_len) -{ - size_t elen = strlen(encoded); - size_t dlen = 0; - if (mbedtls_base64_decode(NULL, 0, &dlen, - (const unsigned char *)encoded, elen) != MBEDTLS_ERR_BASE64_BUFFER_TOO_SMALL) - return NULL; - char *out = (char *)malloc(dlen + 1); - if (!out) return NULL; - if (mbedtls_base64_decode((unsigned char *)out, dlen, &dlen, - (const unsigned char *)encoded, elen) != 0) { - free(out); - return NULL; - } - out[dlen] = '\0'; - if (out_len) *out_len = dlen; - return out; -} - -/* -- Parse token -> TAI connection params ------------------------------- */ - -typedef struct { - char host[256]; - char tls_sni[256]; - char derived_client_id[256]; - char agent_token[256]; - uint16_t port; - long biz_code; - long biz_tag; -} tai_conn_params_t; - -static int parse_token(const char *raw_token, tai_conn_params_t *p) -{ - memset(p, 0, sizeof(*p)); - - char *json = NULL; - { - size_t dl = 0; - char *decoded = b64_decode(raw_token, &dl); - if (decoded && dl > 0 && decoded[0] == '{') { - json = decoded; - } else { - free(decoded); - json = strdup(raw_token); - } - } - if (!json) return -1; - - char *conn = json_get_object(json, "connect_conf"); - if (!conn) { - fprintf(stderr, "[parse_token] 'connect_conf' not found\n"); - free(json); - return -1; - } - - json_array_first_string(conn, "hosts", p->host, sizeof(p->host)); - - if (json_array_first_string(conn, "domains", p->tls_sni, sizeof(p->tls_sni)) != 0) - strncpy(p->tls_sni, p->host, sizeof(p->tls_sni) - 1); - - long port = 0; - if (json_get_long(conn, "ecc_tls_port", &port) != 0) - json_get_long(conn, "tcpport", &port); - p->port = (port > 0) ? (uint16_t)port : 443; - - json_get_string(conn, "derived_client_id", - p->derived_client_id, sizeof(p->derived_client_id)); - free(conn); - - char *sess = json_get_object(json, "session_conf"); - if (sess) { - json_get_string(sess, "agentToken", - p->agent_token, sizeof(p->agent_token)); - char *biz = json_get_object(sess, "bizConfig"); - if (biz) { - json_get_long(biz, "bizCode", &p->biz_code); - json_get_long(biz, "bizTag", &p->biz_tag); - free(biz); - } - free(sess); - } - - free(json); - - if (p->host[0] == '\0') { - fprintf(stderr, "[parse_token] Could not extract host\n"); - return -1; - } - return 0; -} - /* -- main --------------------------------------------------------------- */ int main(int argc, char *argv[]) @@ -377,9 +209,10 @@ int main(int argc, char *argv[]) .mqtt_disable_tls = false, .message_callback = NULL, }; - memcpy((char *)iot_cfg.devid, devid, strlen(devid)); - memcpy((char *)iot_cfg.secret_key, secret_key, strlen(secret_key)); - memcpy((char *)iot_cfg.local_key, local_key, strlen(local_key)); + if (demo_copy_field((char *)iot_cfg.devid, sizeof(iot_cfg.devid), devid, "devid") != 0 || + demo_copy_field((char *)iot_cfg.secret_key, sizeof(iot_cfg.secret_key), secret_key, "secret_key") != 0 || + demo_copy_field((char *)iot_cfg.local_key, sizeof(iot_cfg.local_key), local_key, "local_key") != 0) + return 1; iot_client_t *iot = iot_client_init(&iot_cfg); if (!iot) { diff --git a/examples/posix/ai/rtc-tcp-client/mcp_demo.c b/examples/posix/ai/rtc-tcp-client/mcp_demo.c index 46228c3..c62e252 100644 --- a/examples/posix/ai/rtc-tcp-client/mcp_demo.c +++ b/examples/posix/ai/rtc-tcp-client/mcp_demo.c @@ -31,11 +31,12 @@ #include #include -#include "mbedtls/base64.h" - #include "tuya_ai.h" #include "iot_client.h" +#include "demo_json.h" +#include "demo_mcp.h" #include "demo_reconnect.h" +#include "demo_text.h" extern const pal_t *tai_pal_posix(void); @@ -91,32 +92,8 @@ static int tool_control_device(const char *args_json, char *out, size_t out_cap) char action[32] = {0}; char target[32] = {0}; - const char *p = strstr(args_json ? args_json : "", "\"action\""); - if (p) { - p = strchr(p, ':'); - if (p) { - p++; - while (*p == ' ' || *p == '\"') p++; - const char *end = strchr(p, '\"'); - if (end && (size_t)(end - p) < sizeof(action)) { - memcpy(action, p, (size_t)(end - p)); - action[end - p] = '\0'; - } - } - } - p = strstr(args_json ? args_json : "", "\"target\""); - if (p) { - p = strchr(p, ':'); - if (p) { - p++; - while (*p == ' ' || *p == '\"') p++; - const char *end = strchr(p, '\"'); - if (end && (size_t)(end - p) < sizeof(target)) { - memcpy(target, p, (size_t)(end - p)); - target[end - p] = '\0'; - } - } - } + json_object_get_string(args_json ? args_json : "", "action", action, sizeof(action)); + json_object_get_string(args_json ? args_json : "", "target", target, sizeof(target)); if (action[0] == '\0' || target[0] == '\0') { return snprintf(out, out_cap, @@ -157,192 +134,6 @@ static const mcp_tool_t *find_tool(const char *name) return NULL; } -/* ------------------------------------------------------------------------- - * Minimal JSON helpers - * - * The TAI payload is JSON-RPC 2.0; we only need to pull out a few scalar - * fields and pass tool arguments through to handlers. No full parser - * needed -- the existing examples in this folder use the same approach. - * ------------------------------------------------------------------------- */ - -static const char *json_find_value(const char *json, const char *key) -{ - if (!json || !key) return NULL; - char search[128]; - snprintf(search, sizeof(search), "\"%s\"", key); - const char *p = strstr(json, search); - if (!p) return NULL; - p += strlen(search); - while (*p == ' ' || *p == ':' || *p == '\t') p++; - return p; -} - -static int json_get_string(const char *json, const char *key, - char *out, size_t cap) -{ - const char *p = json_find_value(json, key); - if (!p || *p != '\"') return -1; - p++; - const char *end = strchr(p, '\"'); - if (!end) return -1; - size_t len = (size_t)(end - p); - if (len >= cap) len = cap - 1; - memcpy(out, p, len); - out[len] = '\0'; - return 0; -} - -/* Find a sub-object's raw text so we can hand it to a handler. Returns - * a freshly allocated string the caller must free. */ -static char *json_get_object_raw(const char *json, const char *key) -{ - const char *p = json_find_value(json, key); - if (!p || *p != '{') return NULL; - int depth = 0; - const char *start = p, *q = p; - while (*q) { - if (*q == '{') depth++; - else if (*q == '}' && --depth == 0) { - size_t len = (size_t)(q - start + 1); - char *obj = (char *)malloc(len + 1); - if (obj) { memcpy(obj, start, len); obj[len] = '\0'; } - return obj; - } - q++; - } - return NULL; -} - -/* Copy the request's "id" token verbatim (number or quoted string). */ -static int copy_id(const char *request, char *out, size_t cap) -{ - const char *p = json_find_value(request, "id"); - if (!p) { - if (cap > 0) out[0] = '\0'; - return -1; - } - /* Skip the value: number (digits/-/./e/E) or quoted string. */ - size_t n = 0; - if (*p == '\"') { - const char *end = strchr(p + 1, '\"'); - if (!end) return -1; - n = (size_t)(end - p + 1); - } else { - const char *end = p; - while (*end && *end != ',' && *end != '}' && - *end != ' ' && *end != '\n' && *end != '\r') end++; - n = (size_t)(end - p); - } - if (n + 1 > cap) n = cap - 1; - memcpy(out, p, n); - out[n] = '\0'; - return 0; -} - -/* ------------------------------------------------------------------------- - * Base64 + token parsing (mirrors the other rtc-tcp-client examples) - * ------------------------------------------------------------------------- */ - -static char *b64_decode(const char *encoded, size_t *out_len) -{ - size_t elen = strlen(encoded); - size_t dlen = 0; - if (mbedtls_base64_decode(NULL, 0, &dlen, - (const unsigned char *)encoded, elen) - != MBEDTLS_ERR_BASE64_BUFFER_TOO_SMALL) - return NULL; - char *out = (char *)malloc(dlen + 1); - if (!out) return NULL; - if (mbedtls_base64_decode((unsigned char *)out, dlen, &dlen, - (const unsigned char *)encoded, elen) != 0) { - free(out); - return NULL; - } - out[dlen] = '\0'; - if (out_len) *out_len = dlen; - return out; -} - -typedef struct { - char host[256]; - char tls_sni[256]; - char derived_client_id[256]; - char agent_token[256]; - uint16_t port; - long biz_code; - long biz_tag; -} tai_conn_params_t; - -static int json_array_first_string(const char *json, const char *key, - char *out, size_t cap) -{ - const char *p = json_find_value(json, key); - if (!p || *p != '[') return -1; - p++; - while (*p == ' ') p++; - if (*p != '\"') return -1; - p++; - const char *end = strchr(p, '\"'); - if (!end) return -1; - size_t len = (size_t)(end - p); - if (len >= cap) len = cap - 1; - memcpy(out, p, len); - out[len] = '\0'; - return 0; -} - -static int parse_token(const char *raw_token, tai_conn_params_t *p) -{ - memset(p, 0, sizeof(*p)); - char *json = NULL; - { - size_t dl = 0; - char *decoded = b64_decode(raw_token, &dl); - if (decoded && dl > 0 && decoded[0] == '{') { - json = decoded; - } else { - free(decoded); - json = strdup(raw_token); - } - } - if (!json) return -1; - - char *conn = json_get_object_raw(json, "connect_conf"); - if (!conn) { free(json); return -1; } - json_array_first_string(conn, "hosts", p->host, sizeof(p->host)); - if (json_array_first_string(conn, "domains", p->tls_sni, sizeof(p->tls_sni)) != 0) - strncpy(p->tls_sni, p->host, sizeof(p->tls_sni) - 1); - - /* crude numeric scan: look for "ecc_tls_port" : */ - const char *pp = json_find_value(conn, "ecc_tls_port"); - long port = 0; - if (pp) port = strtol(pp, NULL, 10); - p->port = (port > 0) ? (uint16_t)port : 443; - - json_get_string(conn, "derived_client_id", - p->derived_client_id, sizeof(p->derived_client_id)); - free(conn); - - char *sess = json_get_object_raw(json, "session_conf"); - if (sess) { - json_get_string(sess, "agentToken", - p->agent_token, sizeof(p->agent_token)); - char *biz = json_get_object_raw(sess, "bizConfig"); - if (biz) { - const char *bc = json_find_value(biz, "bizCode"); - const char *bt = json_find_value(biz, "bizTag"); - if (bc) p->biz_code = strtol(bc, NULL, 10); - if (bt) p->biz_tag = strtol(bt, NULL, 10); - free(biz); - } - free(sess); - } - free(json); - - if (p->host[0] == '\0') return -1; - return 0; -} - /* ------------------------------------------------------------------------- * MCP response builders * ------------------------------------------------------------------------- */ @@ -448,10 +239,23 @@ static void handle_mcp_request(tai_ctx_t *ctx, const char *payload, size_t len) memcpy(req, payload, len); req[len] = '\0'; - char id[64] = "null"; + /* Top-level id and method only: a tools/call's params.arguments may carry + * keys of the same name, and answering those is answering the wrong + * question. See demo_mcp.h. */ + char id[64]; char method[64] = {0}; - copy_id(req, id, sizeof(id)); - json_get_string(req, "method", method, sizeof(method)); + int have_id = (demo_mcp_copy_id(req, id, sizeof(id)) == 0); + json_object_get_string(req, "method", method, sizeof(method)); + + /* JSON-RPC 2.0 forbids a response to a notification, and an id that cannot + * be echoed verbatim leaves nothing to correlate on either way. */ + if (!have_id) { + fprintf(stderr, "[MCP] <- method=\"%s\" with no usable id: " + "notification, no response\n", + method[0] ? method : "(none)"); + free(req); + return; + } fprintf(stderr, "[MCP] <- method=\"%s\" id=%s\n", method[0] ? method : "(none)", id); @@ -464,10 +268,10 @@ static void handle_mcp_request(tai_ctx_t *ctx, const char *payload, size_t len) } else if (strcmp(method, "tools/list") == 0) { resp_len = build_tools_list_response(id, resp, sizeof(resp)); } else if (strcmp(method, "tools/call") == 0) { - char *params = json_get_object_raw(req, "params"); + char *params = json_object_get_object(req, "params"); char name[64] = {0}; - json_get_string(params ? params : "", "name", name, sizeof(name)); - char *args = json_get_object_raw(params ? params : "", "arguments"); + json_object_get_string(params ? params : "", "name", name, sizeof(name)); + char *args = json_object_get_object(params ? params : "", "arguments"); fprintf(stderr, "[MCP] tools/call: name=\"%s\" args=%s\n", name, args ? args : "{}"); resp_len = build_tools_call_response(id, name, args, resp, sizeof(resp)); @@ -494,42 +298,13 @@ static void handle_mcp_request(tai_ctx_t *ctx, const char *payload, size_t len) * TAI callbacks * ------------------------------------------------------------------------- */ -/* Extract data.content from an NLG JSON line. Returns a pointer into - * `text` (not a copy). Returns NULL if the line is not NLG or - * content is missing. */ -static const char *nlg_extract_content(const char *text, size_t len, - size_t *out_len) -{ - /* Quick guard: must contain "NLG" and "content" */ - if (!strstr(text, "\"NLG\"") || !strstr(text, "\"content\"")) - return NULL; - - const char *p = strstr(text, "\"content\""); - if (!p) return NULL; - p = strchr(p, ':'); - if (!p) return NULL; - p++; - while (*p == ' ' || *p == '\"') p++; - - const char *end = strchr(p, '\"'); - if (!end) return NULL; - - *out_len = (size_t)(end - p); - return p; -} - static void on_text(tai_ctx_t *ctx, const tai_text_msg_t *msg, void *ud) { (void)ctx; (void)ud; - /* For NLG lines, print only the content field. */ - size_t clen = 0; - const char *content = nlg_extract_content(msg->text, msg->len, &clen); - if (content && clen > 0) { - fwrite(content, 1, clen, stdout); - fflush(stdout); - return; - } + /* For NLG lines, print only the content field, escapes decoded. Handles the + * empty terminator line too — which is NLG, so it must not fall through. */ + if (nlg_print_content(msg->text, msg->len)) return; /* Non-NLG text: print raw. */ fwrite(msg->text, 1, msg->len, stdout); @@ -590,9 +365,10 @@ int main(int argc, char *argv[]) .mqtt_disable_tls = false, .message_callback = NULL, }; - memcpy((char *)iot_cfg.devid, devid, strlen(devid)); - memcpy((char *)iot_cfg.secret_key, secret_key, strlen(secret_key)); - memcpy((char *)iot_cfg.local_key, local_key, strlen(local_key)); + if (demo_copy_field((char *)iot_cfg.devid, sizeof(iot_cfg.devid), devid, "devid") != 0 || + demo_copy_field((char *)iot_cfg.secret_key, sizeof(iot_cfg.secret_key), secret_key, "secret_key") != 0 || + demo_copy_field((char *)iot_cfg.local_key, sizeof(iot_cfg.local_key), local_key, "local_key") != 0) + return 1; iot_client_t *iot = iot_client_init(&iot_cfg); if (!iot) { fprintf(stderr, "iot_client_init failed\n"); return 1; } diff --git a/examples/posix/ai/rtc-tcp-client/music_play_demo.c b/examples/posix/ai/rtc-tcp-client/music_play_demo.c index f60e837..d16c645 100644 --- a/examples/posix/ai/rtc-tcp-client/music_play_demo.c +++ b/examples/posix/ai/rtc-tcp-client/music_play_demo.c @@ -3,7 +3,7 @@ * * Sends a text query that triggers the server's music skill, parses the * returned audio metadata (artist / album / song / url), prints it, and - * downloads the mp3 trial clip via curl. + * downloads the mp3 trial clip with curl. * * Build: * cmake -S examples/posix -B build -DAGENTIC_KIT_BUILD_EXAMPLES=ON @@ -20,17 +20,19 @@ */ #include +#include #include #include #include #include #include -#include "mbedtls/base64.h" - #include "tuya_ai.h" #include "iot_client.h" +#include "demo_json.h" +#include "demo_mcp.h" #include "demo_reconnect.h" +#include "demo_text.h" extern const pal_t *tai_pal_posix(void); @@ -51,6 +53,8 @@ typedef struct { volatile int music_state; /* try_parse_music result: 0 none seen, 1 parsed and printed, -1 unreadable */ char music_url[1024];/* trial-clip URL, if any */ + demo_textbuf_t text; /* reassembles a chunked text stream */ + int stream_printed; /* this stream already printed NLG prose */ demo_reconnect_t reconn; } demo_ctx_t; @@ -152,203 +156,6 @@ static void box_field(const char *label, const char *value) printf("|\n"); } -/* ------------------------------------------------------------------------- - * Minimal JSON helpers - * ------------------------------------------------------------------------- */ - -static const char *json_find_value(const char *json, const char *key) -{ - if (!json || !key) return NULL; - char search[128]; - snprintf(search, sizeof(search), "\"%s\"", key); - const char *p = strstr(json, search); - if (!p) return NULL; - p += strlen(search); - while (*p == ' ' || *p == ':' || *p == '\t') p++; - return p; -} - -static int json_get_string(const char *json, const char *key, - char *out, size_t cap) -{ - const char *p = json_find_value(json, key); - if (!p || *p != '\"') return -1; - p++; - const char *end = strchr(p, '\"'); - if (!end) return -1; - size_t len = (size_t)(end - p); - if (len >= cap) len = cap - 1; - memcpy(out, p, len); - out[len] = '\0'; - return 0; -} - -static char *json_get_object_raw(const char *json, const char *key) -{ - const char *p = json_find_value(json, key); - if (!p || *p != '{') return NULL; - int depth = 0; - const char *start = p, *q = p; - while (*q) { - if (*q == '{') depth++; - else if (*q == '}' && --depth == 0) { - size_t len = (size_t)(q - start + 1); - char *obj = (char *)malloc(len + 1); - if (obj) { memcpy(obj, start, len); obj[len] = '\0'; } - return obj; - } - q++; - } - return NULL; -} - -static char *json_get_array_raw(const char *json, const char *key) -{ - const char *p = json_find_value(json, key); - if (!p || *p != '[') return NULL; - int depth = 0; - const char *start = p, *q = p; - while (*q) { - if (*q == '[') depth++; - else if (*q == ']' && --depth == 0) { - size_t len = (size_t)(q - start + 1); - char *obj = (char *)malloc(len + 1); - if (obj) { memcpy(obj, start, len); obj[len] = '\0'; } - return obj; - } - q++; - } - return NULL; -} - -static int json_array_first_string(const char *json, const char *key, - char *out, size_t cap) -{ - const char *p = json_find_value(json, key); - if (!p || *p != '[') return -1; - p++; - while (*p == ' ') p++; - if (*p != '\"') return -1; - p++; - const char *end = strchr(p, '\"'); - if (!end) return -1; - size_t len = (size_t)(end - p); - if (len >= cap) len = cap - 1; - memcpy(out, p, len); - out[len] = '\0'; - return 0; -} - -/* ------------------------------------------------------------------------- - * Base64 + token parsing - * ------------------------------------------------------------------------- */ - -static char *b64_decode(const char *encoded, size_t *out_len) -{ - size_t elen = strlen(encoded); - size_t dlen = 0; - if (mbedtls_base64_decode(NULL, 0, &dlen, - (const unsigned char *)encoded, elen) - != MBEDTLS_ERR_BASE64_BUFFER_TOO_SMALL) - return NULL; - char *out = (char *)malloc(dlen + 1); - if (!out) return NULL; - if (mbedtls_base64_decode((unsigned char *)out, dlen, &dlen, - (const unsigned char *)encoded, elen) != 0) { - free(out); - return NULL; - } - out[dlen] = '\0'; - if (out_len) *out_len = dlen; - return out; -} - -typedef struct { - char host[256]; - char tls_sni[256]; - char derived_client_id[256]; - char agent_token[256]; - uint16_t port; - long biz_code; - long biz_tag; -} tai_conn_params_t; - -static int parse_token(const char *raw_token, tai_conn_params_t *p) -{ - memset(p, 0, sizeof(*p)); - char *json = NULL; - { - size_t dl = 0; - char *decoded = b64_decode(raw_token, &dl); - if (decoded && dl > 0 && decoded[0] == '{') { - json = decoded; - } else { - free(decoded); - json = strdup(raw_token); - } - } - if (!json) return -1; - - char *conn = json_get_object_raw(json, "connect_conf"); - if (!conn) { free(json); return -1; } - json_array_first_string(conn, "hosts", p->host, sizeof(p->host)); - if (json_array_first_string(conn, "domains", p->tls_sni, sizeof(p->tls_sni)) != 0) - strncpy(p->tls_sni, p->host, sizeof(p->tls_sni) - 1); - - const char *pp = json_find_value(conn, "ecc_tls_port"); - long port = 0; - if (pp) port = strtol(pp, NULL, 10); - p->port = (port > 0) ? (uint16_t)port : 443; - - json_get_string(conn, "derived_client_id", - p->derived_client_id, sizeof(p->derived_client_id)); - free(conn); - - char *sess = json_get_object_raw(json, "session_conf"); - if (sess) { - json_get_string(sess, "agentToken", - p->agent_token, sizeof(p->agent_token)); - char *biz = json_get_object_raw(sess, "bizConfig"); - if (biz) { - const char *bc = json_find_value(biz, "bizCode"); - const char *bt = json_find_value(biz, "bizTag"); - if (bc) p->biz_code = strtol(bc, NULL, 10); - if (bt) p->biz_tag = strtol(bt, NULL, 10); - free(biz); - } - free(sess); - } - free(json); - - if (p->host[0] == '\0') return -1; - return 0; -} - -/* ------------------------------------------------------------------------- - * NLG content extraction (for clean streaming output) - * ------------------------------------------------------------------------- */ - -static const char *nlg_extract_content(const char *text, size_t len, - size_t *out_len) -{ - (void)len; - if (!strstr(text, "\"NLG\"") || !strstr(text, "\"content\"")) - return NULL; - - const char *p = strstr(text, "\"content\""); - if (!p) return NULL; - p = strchr(p, ':'); - if (!p) return NULL; - p++; - while (*p == ' ' || *p == '\"') p++; - - const char *end = strchr(p, '\"'); - if (!end) return NULL; - - *out_len = (size_t)(end - p); - return p; -} - /* ------------------------------------------------------------------------- * Music response parsing * @@ -369,7 +176,7 @@ static int is_music_response(const char *text) if (strstr(text, "\"code\":\"music\"")) return 1; if (!strstr(text, "music")) return 0; /* cheap reject for NLG chatter */ - char *data = json_get_object_raw(text, "data"); + char *data = json_get_object(text, "data"); char code[32]; int music = data && json_get_string(data, "code", code, sizeof(code)) == 0 && @@ -385,27 +192,15 @@ static int is_music_response(const char *text) static int try_parse_music(const char *text, char *url_out, size_t url_cap) { int rc = -1; - char *general = json_get_object_raw(text, "general"); - char *gdata = general ? json_get_object_raw(general, "data") : NULL; - char *audios = gdata ? json_get_array_raw(gdata, "audios") : NULL; - char *first = NULL; - - url_out[0] = '\0'; - if (audios) { - const char *open = strchr(audios, '{'); - if (open) { - int depth = 0; - for (const char *q = open; *q; q++) { - if (*q == '{') depth++; - else if (*q == '}' && --depth == 0) { - size_t olen = (size_t)(q - open + 1); - first = (char *)malloc(olen + 1); - if (first) { memcpy(first, open, olen); first[olen] = '\0'; } - break; - } - } - } - } + char *general = json_get_object(text, "general"); + char *gdata = general ? json_get_object(general, "data") : NULL; + char *audios = gdata ? json_get_array(gdata, "audios") : NULL; + char *first = audios ? json_array_first_object(audios) : NULL; + char url[1024] = {0}; + + /* url_out is written only on success: a turn can carry several music + * frames, and a later play-state frame with no audios must not erase the + * URL an earlier one supplied. */ if (!first) goto out; { @@ -416,21 +211,30 @@ static int try_parse_music(const char *text, char *url_out, size_t url_cap) char audio_id[128] = {0}; char image_url[512] = {0}; - json_get_string(first, "name", name, sizeof(name)); - json_get_string(first, "artist", artist, sizeof(artist)); - json_get_string(first, "album", album, sizeof(album)); - json_get_string(first, "format", format, sizeof(format)); - json_get_string(first, "audioId", audio_id, sizeof(audio_id)); - json_get_string(first, "imageUrl", image_url, sizeof(image_url)); - json_get_string(first, "url", url_out, url_cap); - - /* A URL that cannot be downloaded is a parse failure, not "no URL". */ - if (url_out[0] && - strncmp(url_out, "http://", 7) != 0 && - strncmp(url_out, "https://", 8) != 0) { - fprintf(stderr, "[text] rejecting non-http(s) audio URL\n"); - url_out[0] = '\0'; - goto out; + /* These are printed and nothing else, so a value too long for its + * buffer is truncated. json_get_string() would empty it instead — the + * right call for a credential, but it turns a long compilation title + * into "(unknown)" and makes a long cover URL vanish outright. */ + json_get_display_string(first, "name", name, sizeof(name)); + json_get_display_string(first, "artist", artist, sizeof(artist)); + json_get_display_string(first, "album", album, sizeof(album)); + json_get_display_string(first, "format", format, sizeof(format)); + json_get_display_string(first, "audioId", audio_id, sizeof(audio_id)); + json_get_display_string(first, "imageUrl", image_url, sizeof(image_url)); + + /* An unusable URL is a parse failure, not a silent "no URL". */ + if (json_find_value(first, "url")) { + if (json_get_string(first, "url", url, sizeof(url)) != 0) { + fprintf(stderr, "[text] audio URL does not fit (%zu-byte buffer)\n", + sizeof(url)); + goto out; + } + if (url[0] && + strncmp(url, "http://", 7) != 0 && + strncmp(url, "https://", 8) != 0) { + fprintf(stderr, "[text] rejecting non-http(s) audio URL\n"); + goto out; + } } printf("\n"); @@ -443,10 +247,21 @@ static int try_parse_music(const char *text, char *url_out, size_t url_cap) box_field("Format", format[0] ? format : "?"); box_field("AudioID", audio_id[0] ? audio_id : "?"); box_rule(); - if (url_out[0]) printf(" Audio : %s\n", url_out); + if (url[0]) printf(" Audio : %s\n", url); if (image_url[0]) printf(" Cover : %s\n", image_url); printf("\n"); + if (url[0]) { + size_t ulen = strlen(url); + /* Reporting success while leaving url_out at whatever it held would + * make main() download a stale URL, or none, and call it a parse. */ + if (ulen >= url_cap) { + fprintf(stderr, "[text] audio URL does not fit the caller's " + "%zu-byte field\n", url_cap); + goto out; + } + memcpy(url_out, url, ulen + 1); + } rc = 1; } @@ -462,33 +277,53 @@ static int try_parse_music(const char *text, char *url_out, size_t url_cap) * TAI callbacks * ------------------------------------------------------------------------- */ -static void on_text(tai_ctx_t *ctx, const tai_text_msg_t *msg, void *ud) +/* Runs once per reassembled text stream. All callbacks arrive on the one SDK + * worker thread, so dc needs no locking. */ +static void handle_complete_text(demo_ctx_t *dc) { - (void)ctx; - demo_ctx_t *dc = (demo_ctx_t *)ud; - - /* Try to parse as a music skill response. */ - if (is_music_response(msg->text)) { - dc->music_state = try_parse_music(msg->text, dc->music_url, - sizeof(dc->music_url)); + if (is_music_response(dc->text.buf)) { + int rc = try_parse_music(dc->text.buf, dc->music_url, + sizeof(dc->music_url)); + /* A turn can carry several music frames — a later one that carries no + * audios must not undo an earlier success. */ + if (dc->music_state != 1) dc->music_state = rc; return; } - /* For NLG lines, print only the content field. */ - size_t clen = 0; - const char *content = nlg_extract_content(msg->text, msg->len, &clen); - if (content && clen > 0) { - fwrite(content, 1, clen, stdout); - fflush(stdout); - return; - } + /* NLG prose already printed chunk-by-chunk; anything else is dumped raw + * once, as a whole document rather than as fragments. */ + if (dc->stream_printed) return; - /* Non-NLG, non-music text: print raw. */ - fwrite(msg->text, 1, msg->len, stdout); + fwrite(dc->text.buf, 1, dc->text.len, stdout); fputc('\n', stdout); fflush(stdout); } +static void on_text(tai_ctx_t *ctx, const tai_text_msg_t *msg, void *ud) +{ + (void)ctx; + demo_ctx_t *dc = (demo_ctx_t *)ud; + + if (msg->stream_flag == TAI_STREAM_START || + msg->stream_flag == TAI_STREAM_ONE_SHOT) + dc->stream_printed = 0; + + /* NLG prose is one self-contained JSON line per chunk: print as it + * arrives. Bounded by msg->len — the slice carries no terminator. */ + if (nlg_print_content(msg->text, msg->len)) + dc->stream_printed = 1; /* even an empty terminator line counts */ + + /* In parallel, reassemble: a SKILL response is one JSON document that can + * straddle chunk boundaries, so it is parsed only once the stream ends. */ + int complete = demo_textbuf_accum(&dc->text, msg); + if (complete < 0) { + /* demo_textbuf_accum has already named the reason on stderr, and has + * counted the loss in dc->text.dropped, which main() reports on. */ + return; + } + if (complete == 1) handle_complete_text(dc); +} + static void on_audio(tai_ctx_t *ctx, const tai_audio_msg_t *msg, void *ud) { (void)ctx; (void)msg; (void)ud; @@ -498,12 +333,15 @@ static void on_event(tai_ctx_t *ctx, const tai_event_msg_t *msg, void *ud) { demo_ctx_t *dc = (demo_ctx_t *)ud; if (msg->event_type == TAI_EVT_END) { + /* The SDK drops empty text frames, so a stream ended by a bare + * zero-length END never completes in on_text. Backstop. */ + if (demo_textbuf_flush(&dc->text)) + handle_complete_text(dc); dc->got_done = 1; } else if (msg->event_type == TAI_EVT_MCP_CMD) { - const char *empty_result = - "{\"jsonrpc\":\"2.0\",\"id\":1," - "\"result\":{\"content\":[{\"type\":\"text\",\"text\":\"\"}]}}"; - tai_send_mcp_response(ctx, empty_result); + /* No tools here, but MCP support is declared, so the server is still + * owed a well-formed answer. See demo_mcp.h. */ + demo_mcp_reply_no_tools(ctx, msg); } } @@ -570,20 +408,6 @@ static int download_mp3(const char *url, const char *outfile) * main * ------------------------------------------------------------------------- */ -/* iot_client_config_t's credential fields are fixed-size char arrays; a value - * that does not fit is rejected rather than overflowing them. */ -static int cfg_set(char *dst, size_t cap, const char *src, const char *what) -{ - size_t n = strlen(src); - if (n >= cap) { - fprintf(stderr, "%s is too long (%zu bytes; max %zu)\n", what, n, cap - 1); - return -1; - } - memcpy(dst, src, n); - dst[n] = '\0'; - return 0; -} - int main(int argc, char *argv[]) { const char *query = (argc >= 2) ? argv[1] : DEFAULT_QUERY; @@ -596,7 +420,6 @@ int main(int argc, char *argv[]) printf("Query : %s\n", query); /* ---- 1. iot-sdk init ------------------------------------------------ */ - iot_init_default(); iot_client_config_t iot_cfg = { .devid = {0}, .secret_key = {0}, @@ -609,11 +432,13 @@ int main(int argc, char *argv[]) .schema_id = NULL, .dp_state = NULL, }; - if (cfg_set((char *)iot_cfg.devid, sizeof(iot_cfg.devid), devid, "devid") != 0 || - cfg_set((char *)iot_cfg.secret_key, sizeof(iot_cfg.secret_key), secret_key, "secret_key") != 0 || - cfg_set((char *)iot_cfg.local_key, sizeof(iot_cfg.local_key), local_key, "local_key") != 0) + if (demo_copy_field(iot_cfg.devid, sizeof(iot_cfg.devid), devid, "devid") != 0 || + demo_copy_field(iot_cfg.secret_key, sizeof(iot_cfg.secret_key), secret_key, "secret_key") != 0 || + demo_copy_field(iot_cfg.local_key, sizeof(iot_cfg.local_key), local_key, "local_key") != 0) return 1; + iot_init_default(); + iot_client_t *iot = iot_client_init(&iot_cfg); if (!iot) { fprintf(stderr, "iot_client_init failed\n"); return 1; } @@ -648,11 +473,12 @@ int main(int argc, char *argv[]) demo_ctx_t dc; memset(&dc, 0, sizeof(dc)); - static const char SESSION_ATTRS[] = - "{\"deviceMcp\":{\"supportCustomMCP\":true}}"; - static const char EVENT_USER_DATA[] = - "{\"sys.workflow\":\"asr-llm-tts\"}"; - + /* session_attrs_json / event_user_data_json are left NULL: the SDK's + * built-in defaults already declare deviceMcp and the asr-llm-tts + * workflow. Setting either one REPLACES the default wholesale rather than + * merging into it, so spelling out only the keys this demo cares about + * would silently drop tts.order.supports, asr.enableVad, tts.alternate and + * processing.interrupt. */ tai_config_t tai_cfg = { .host = cp.host, .port = cp.port, @@ -665,8 +491,6 @@ int main(int argc, char *argv[]) .biz_code = (uint32_t)cp.biz_code, .biz_tag = (uint64_t)cp.biz_tag, .agent_token = cp.agent_token, - .session_attrs_json = SESSION_ATTRS, - .event_user_data_json = EVENT_USER_DATA, .pal = pal, .on_text = on_text, .on_audio = on_audio, @@ -730,7 +554,10 @@ int main(int argc, char *argv[]) } else { fprintf(stderr, "\n[main] disconnected (reason=%u code=%u)\n", dc.reconn.reason, dc.reconn.close_code); - tai_disconnect(ctx); + tai_disconnect(ctx); /* joins the worker: dc is ours again */ + /* A half-received stream from the dead connection must not + * prefix the first stream of the new one. */ + demo_textbuf_reset(&dc.text); if (demo_reconnect_tripped(&dc.reconn)) { fprintf(stderr, "[main] circuit breaker: giving up after %d attempts\n", dc.reconn.attempt); @@ -756,6 +583,16 @@ int main(int argc, char *argv[]) /* ---- 9. Download the trial clip ------------------------------------ */ int failed = !dc.got_done; + + /* A text stream the accumulator had to give up on may well have been the + * music response; reporting "no music skill response" and exiting 0 would + * hand a caller a success for a run that lost its payload. */ + if (dc.text.dropped) { + fprintf(stderr, "[main] %u text stream(s) were dropped; the music " + "response may have been among them\n", dc.text.dropped); + failed = 1; + } + if (dc.music_url[0]) { if (download_mp3(dc.music_url, OUTPUT_FILE) != 0) failed = 1; } else if (dc.music_state < 0) { @@ -767,6 +604,8 @@ int main(int argc, char *argv[]) printf("[main] music response carried no audio URL\n"); } + demo_textbuf_free(&dc.text); + printf("\nDone.\n"); return failed ? 1 : 0; } diff --git a/examples/posix/ai/rtc-tcp-client/text_chat_demo.c b/examples/posix/ai/rtc-tcp-client/text_chat_demo.c index db40b5b..e0cfbe2 100644 --- a/examples/posix/ai/rtc-tcp-client/text_chat_demo.c +++ b/examples/posix/ai/rtc-tcp-client/text_chat_demo.c @@ -21,11 +21,12 @@ #include #include -#include "mbedtls/base64.h" - #include "tuya_ai.h" #include "iot_client.h" +#include "demo_json.h" +#include "demo_mcp.h" #include "demo_reconnect.h" +#include "demo_text.h" extern const pal_t *tai_pal_posix(void); @@ -44,183 +45,6 @@ typedef struct { demo_reconnect_t reconn; /* app-side reconnect policy/state */ } demo_ctx_t; -/* ------------------------------------------------------------------------- - * Minimal JSON helpers - * ------------------------------------------------------------------------- */ - -static const char *json_find_value(const char *json, const char *key) -{ - if (!json || !key) return NULL; - char search[128]; - snprintf(search, sizeof(search), "\"%s\"", key); - const char *p = strstr(json, search); - if (!p) return NULL; - p += strlen(search); - while (*p == ' ' || *p == ':' || *p == '\t') p++; - return p; -} - -static int json_get_string(const char *json, const char *key, - char *out, size_t cap) -{ - const char *p = json_find_value(json, key); - if (!p || *p != '\"') return -1; - p++; - const char *end = strchr(p, '\"'); - if (!end) return -1; - size_t len = (size_t)(end - p); - if (len >= cap) len = cap - 1; - memcpy(out, p, len); - out[len] = '\0'; - return 0; -} - -static char *json_get_object_raw(const char *json, const char *key) -{ - const char *p = json_find_value(json, key); - if (!p || *p != '{') return NULL; - int depth = 0; - const char *start = p, *q = p; - while (*q) { - if (*q == '{') depth++; - else if (*q == '}' && --depth == 0) { - size_t len = (size_t)(q - start + 1); - char *obj = (char *)malloc(len + 1); - if (obj) { memcpy(obj, start, len); obj[len] = '\0'; } - return obj; - } - q++; - } - return NULL; -} - -static int json_array_first_string(const char *json, const char *key, - char *out, size_t cap) -{ - const char *p = json_find_value(json, key); - if (!p || *p != '[') return -1; - p++; - while (*p == ' ') p++; - if (*p != '\"') return -1; - p++; - const char *end = strchr(p, '\"'); - if (!end) return -1; - size_t len = (size_t)(end - p); - if (len >= cap) len = cap - 1; - memcpy(out, p, len); - out[len] = '\0'; - return 0; -} - -/* ------------------------------------------------------------------------- - * Base64 + token parsing - * ------------------------------------------------------------------------- */ - -static char *b64_decode(const char *encoded, size_t *out_len) -{ - size_t elen = strlen(encoded); - size_t dlen = 0; - if (mbedtls_base64_decode(NULL, 0, &dlen, - (const unsigned char *)encoded, elen) - != MBEDTLS_ERR_BASE64_BUFFER_TOO_SMALL) - return NULL; - char *out = (char *)malloc(dlen + 1); - if (!out) return NULL; - if (mbedtls_base64_decode((unsigned char *)out, dlen, &dlen, - (const unsigned char *)encoded, elen) != 0) { - free(out); - return NULL; - } - out[dlen] = '\0'; - if (out_len) *out_len = dlen; - return out; -} - -typedef struct { - char host[256]; - char tls_sni[256]; - char derived_client_id[256]; - char agent_token[256]; - uint16_t port; - long biz_code; - long biz_tag; -} tai_conn_params_t; - -static int parse_token(const char *raw_token, tai_conn_params_t *p) -{ - memset(p, 0, sizeof(*p)); - char *json = NULL; - { - size_t dl = 0; - char *decoded = b64_decode(raw_token, &dl); - if (decoded && dl > 0 && decoded[0] == '{') { - json = decoded; - } else { - free(decoded); - json = strdup(raw_token); - } - } - if (!json) return -1; - - char *conn = json_get_object_raw(json, "connect_conf"); - if (!conn) { free(json); return -1; } - json_array_first_string(conn, "hosts", p->host, sizeof(p->host)); - if (json_array_first_string(conn, "domains", p->tls_sni, sizeof(p->tls_sni)) != 0) - strncpy(p->tls_sni, p->host, sizeof(p->tls_sni) - 1); - - const char *pp = json_find_value(conn, "ecc_tls_port"); - long port = 0; - if (pp) port = strtol(pp, NULL, 10); - p->port = (port > 0) ? (uint16_t)port : 443; - - json_get_string(conn, "derived_client_id", - p->derived_client_id, sizeof(p->derived_client_id)); - free(conn); - - char *sess = json_get_object_raw(json, "session_conf"); - if (sess) { - json_get_string(sess, "agentToken", - p->agent_token, sizeof(p->agent_token)); - char *biz = json_get_object_raw(sess, "bizConfig"); - if (biz) { - const char *bc = json_find_value(biz, "bizCode"); - const char *bt = json_find_value(biz, "bizTag"); - if (bc) p->biz_code = strtol(bc, NULL, 10); - if (bt) p->biz_tag = strtol(bt, NULL, 10); - free(biz); - } - free(sess); - } - free(json); - - if (p->host[0] == '\0') return -1; - return 0; -} - -/* ------------------------------------------------------------------------- - * NLG content extraction (for clean streaming output) - * ------------------------------------------------------------------------- */ - -static const char *nlg_extract_content(const char *text, size_t len, - size_t *out_len) -{ - if (!strstr(text, "\"NLG\"") || !strstr(text, "\"content\"")) - return NULL; - - const char *p = strstr(text, "\"content\""); - if (!p) return NULL; - p = strchr(p, ':'); - if (!p) return NULL; - p++; - while (*p == ' ' || *p == '\"') p++; - - const char *end = strchr(p, '\"'); - if (!end) return NULL; - - *out_len = (size_t)(end - p); - return p; -} - /* ------------------------------------------------------------------------- * TAI callbacks * ------------------------------------------------------------------------- */ @@ -229,14 +53,9 @@ static void on_text(tai_ctx_t *ctx, const tai_text_msg_t *msg, void *ud) { (void)ctx; (void)ud; - /* For NLG lines, print only the content field. */ - size_t clen = 0; - const char *content = nlg_extract_content(msg->text, msg->len, &clen); - if (content && clen > 0) { - fwrite(content, 1, clen, stdout); - fflush(stdout); - return; - } + /* For NLG lines, print only the content field, escapes decoded. Handles the + * empty terminator line too — which is NLG, so it must not fall through. */ + if (nlg_print_content(msg->text, msg->len)) return; /* Non-NLG text: print raw. */ fwrite(msg->text, 1, msg->len, stdout); @@ -255,11 +74,9 @@ static void on_event(tai_ctx_t *ctx, const tai_event_msg_t *msg, void *ud) if (msg->event_type == TAI_EVT_END) { dc->got_done = 1; } else if (msg->event_type == TAI_EVT_MCP_CMD) { - /* Minimal MCP response: empty tools list */ - const char *empty_result = - "{\"jsonrpc\":\"2.0\",\"id\":1," - "\"result\":{\"content\":[{\"type\":\"text\",\"text\":\"\"}]}}"; - tai_send_mcp_response(ctx, empty_result); + /* This demo exposes no tools, but it does declare MCP support, so it + * still owes the server a well-formed answer. See demo_mcp.h. */ + demo_mcp_reply_no_tools(ctx, msg); } } @@ -300,9 +117,10 @@ int main(int argc, char *argv[]) .schema_id = NULL, .dp_state = NULL, }; - memcpy((char *)iot_cfg.devid, devid, strlen(devid)); - memcpy((char *)iot_cfg.secret_key, secret_key, strlen(secret_key)); - memcpy((char *)iot_cfg.local_key, local_key, strlen(local_key)); + if (demo_copy_field((char *)iot_cfg.devid, sizeof(iot_cfg.devid), devid, "devid") != 0 || + demo_copy_field((char *)iot_cfg.secret_key, sizeof(iot_cfg.secret_key), secret_key, "secret_key") != 0 || + demo_copy_field((char *)iot_cfg.local_key, sizeof(iot_cfg.local_key), local_key, "local_key") != 0) + return 1; iot_client_t *iot = iot_client_init(&iot_cfg); if (!iot) { fprintf(stderr, "iot_client_init failed\n"); return 1; }