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; }