From 7e62cef09ddb8b4d5e239a199f44fefad3a824be Mon Sep 17 00:00:00 2001 From: cheng Date: Thu, 11 Sep 2025 14:16:55 +0800 Subject: [PATCH] =?utf8?q?=E8=AF=AD=E9=9F=B3=E5=AF=B9=E8=AF=9D=E6=8E=A5?= =?utf8?q?=E5=8F=A3=E5=92=8C=E9=80=89=E6=8B=A9=E6=95=85=E4=BA=8B=E6=8E=A5?= =?utf8?q?=E5=8F=A3=E8=AF=95=E4=B8=8Bmp3=E4=B8=8B=E8=BD=BD=E5=8A=9F?= =?utf8?q?=E8=83=BD?= MIME-Version: 1.0 Content-Type: text/plain; charset=utf8 Content-Transfer-Encoding: 8bit --- code/sdk/zhihui/leiniao/imsl_ln/ln_api.c | 433 ++++++++++++++++-- code/sdk/zhihui/leiniao/imsl_ln/ln_api.h | 11 +- .../leiniao/imsl_ln/ln_api_speech_chat.c | 58 ++- .../leiniao/imsl_ln/ln_api_story_choose.c | 59 ++- 4 files changed, 511 insertions(+), 50 deletions(-) diff --git a/code/sdk/zhihui/leiniao/imsl_ln/ln_api.c b/code/sdk/zhihui/leiniao/imsl_ln/ln_api.c index 5e43cfc13..971e3ac68 100644 --- a/code/sdk/zhihui/leiniao/imsl_ln/ln_api.c +++ b/code/sdk/zhihui/leiniao/imsl_ln/ln_api.c @@ -106,45 +106,6 @@ HTTPC_RESULT ln_http_prepare_auto(HttpClientData *clientData, int header_size, i return ret; } -// 累积并解析SSE数据(补充实现) -void ln_sse_accumulate_and_dispatch(int cmd, const char *data, int data_len, - char *acc, int *acc_len, int acc_size) { - if (!data || data_len <= 0 || !acc || !acc_len || *acc_len >= acc_size) return; - - // 累积数据 - int copy_len = (acc_size - *acc_len - 1) < data_len ? (acc_size - *acc_len - 1) : data_len; - memcpy(acc + *acc_len, data, copy_len); - *acc_len += copy_len; - acc[*acc_len] = '\0'; - - // 分割SSE事件(按"\n\n"分割) - char *ptr = acc; - while ((ptr = strstr(ptr, "\n\n")) != NULL) { - int event_len = ptr - acc; - if (event_len > 0) { - // 提取data字段(去掉"date: "前缀) - char *data_start = strstr(acc, "data: "); - if (data_start) { - data_start += 6; // 跳过"data: " - int json_len = (acc + event_len) - data_start; - char *json = (char *)malloc(json_len + 1); - if (json) { - memcpy(json, data_start, json_len); - json[json_len] = '\0'; - // 投递解析后的JSON数据 - do_ln_http_server_recv_data(cmd, json, json_len); - free(json); - } - } - } - // 移动指针处理剩余数据 - *acc_len -= (ptr - acc + 2); - memmove(acc, ptr + 2, *acc_len); - acc[*acc_len] = '\0'; - ptr = acc; - } -} - /* 计算文件MD5(小写hex)*/ static int ln_calc_file_md5_lower_hex(const char *file_path, char out_hex[33]) { @@ -502,14 +463,12 @@ static void ln_http_server_recv_task(void const *arg) OSAL_MSGQ_WAIT_FOREVER); if (ret != OSAL_SUCCESS) { - printf("[RECV_TASK] 消息读取失败: %d\n", ret); continue; } // 消息有效性检查 if (!mail.data || mail.len <= 0) { - printf("[RECV_TASK] 无效消息: data=%p, len=%u\n", mail.data, mail.len); continue; } @@ -668,3 +627,395 @@ void do_ln_http_server_recv_data(int cmd, char *data, uint32_t len) if (recv_data.data) free(recv_data.data); } } + + +/** + * 高级MP3下载函数,支持自定义保存目录和文件名 + * @param audio_url 音频文件URL + * @param save_dir 保存目录 + * @param filename 文件名(不包含路径) + * @return ERRCODE_SUCC 成功,ERRCODE_FAIL 失败 + */ +static uint32_t ln_download_mp3_file_advanced(const char *audio_url, const char *save_dir, const char *filename) +{ + if (!audio_url || !save_dir || !filename) + { + printf("[SSE] download_mp3_advanced: invalid parameters\n"); + return ERRCODE_FAIL; + } + + // 构建完整保存路径 + char save_path[512]; + snprintf(save_path, sizeof(save_path), "%s/%s", save_dir, filename); + + // 确保目录存在 + if (access(save_dir, F_OK) == -1) + { + uapi_create_folder(save_dir); + } + + printf("[SSE] downloading MP3: %s -> %s\n", audio_url, save_path); + + /* CAT1 网络保活处理 */ + cat1_api_add_cat1_vote(); + cat1_api_wakeup_cat1(1); + cat1_api_keep_wakeup(1); + cat1_api_disable_cat1_cmd(0); + LOS_Msleep(300); + + HttpClient client = {0}; + HttpClientData client_data = {0}; + int ret = -1; + size_t total_written = 0; + + // 打开文件准备写入 + FILE *fd = fopen(save_path, "wb"); + if (!fd) + { + printf("[SSE] fopen failed for %s\n", save_path); + return ERRCODE_FAIL; + } + + HTTPC_RESULT prep = ln_http_prepare_auto(&client_data, 2048, LN_CLIENT_PREPARE_SIZE * 2); + if (prep != HTTP_SUCCESS) + { + printf("[SSE] prepare failed: %d\n", prep); + fclose(fd); + return ERRCODE_FAIL; + } + + // 建立连接 + ret = HttpClientConn(&client, audio_url); + if (ret != 0) + { + printf("[SSE] connection failed, ret=0x%x\n", ret); + fclose(fd); + HttpClientUnprepare(&client_data); + return ERRCODE_FAIL; + } + + // 发送GET请求 + ret = HttpClientSend(&client, audio_url, HTTP_GET, &client_data); + if (ret != 0) + { + printf("[SSE] send request failed, ret=0x%x\n", ret); + fclose(fd); + HttpClientClse(&client); + HttpClientUnprepare(&client_data); + return ERRCODE_FAIL; + } + + // 接收响应数据 + do + { + HTTPC_RESULT recv_res = HttpClientRecvResponse(&client, &client_data); + if (recv_res == HTTP_SUCCESS || recv_res == HTTP_EAGAIN) + { + if (client_data.contentBlockLen > 0) + { + size_t written = fwrite(client_data.responseBuf, 1, client_data.contentBlockLen, fd); + if (written != client_data.contentBlockLen) + { + printf("[SSE] write failed, written=%zu, expected=%d\n", + written, client_data.contentBlockLen); + fclose(fd); + HttpClientClse(&client); + HttpClientUnprepare(&client_data); + return ERRCODE_FAIL; + } + total_written += written; + } + } + else + { + printf("[SSE] receive ended (code: %d)\n", recv_res); + break; + } + } while (client_data.isMore); + + fclose(fd); + HttpClientUnprepare(&client_data); + HttpClientClse(&client); + + printf("[SSE] download completed, total bytes: %zu\n", total_written); + return ERRCODE_SUCC; +} + +/** + * 解析JSON数据,下载MP3并推送消息 + * @param json_data JSON数据字符串 + * @param interface_type 接口类型 + * @param segment_index 片段索引 + * @return ERRCODE_SUCC 成功,ERRCODE_FAIL 失败 + */ +uint32_t ln_parse_and_process_json_data(const char *json_data, int interface_type, int segment_index) +{ + if (!json_data) + { + printf("[JSON_PARSE] invalid json_data\n"); + return ERRCODE_FAIL; + } + + printf("[JSON_PARSE] parsing JSON data (interface_type=%d, segment=%d): %s\n", + interface_type, segment_index, json_data); + + // 解析JSON数据 + cJSON *root = cJSON_Parse(json_data); + if (!root) + { + printf("[JSON_PARSE] JSON parse failed: %s\n", json_data); + return ERRCODE_FAIL; + } + + // 检查是否有错误 + cJSON *errorCode = cJSON_GetObjectItem(root, "errorCode"); + if (errorCode && !cJSON_IsNull(errorCode)) + { + printf("[JSON_PARSE] server error: %s\n", + cJSON_IsString(errorCode) ? errorCode->valuestring : "unknown"); + cJSON_Delete(root); + return ERRCODE_FAIL; + } + + // 获取data字段 + cJSON *data = cJSON_GetObjectItem(root, "data"); + if (!data || !cJSON_IsObject(data)) + { + printf("[JSON_PARSE] no data field found\n"); + cJSON_Delete(root); + return ERRCODE_FAIL; + } + + // 根据接口类型获取音频URL + char *audio_url = NULL; + if (interface_type == LN_HTTP_SERVICE_CMD_SSE_RECV) + { + // 语音对话接口:使用 audioUrl 字段 + cJSON *audioUrl = cJSON_GetObjectItem(data, "audioUrl"); + if (audioUrl && cJSON_IsString(audioUrl)) + { + audio_url = audioUrl->valuestring; + } + } + else if (interface_type == LN_HTTP_RECV_STORY_CHOOSE || interface_type == LN_HTTP_RECV_STORY_RANDOM) + { + // 故事接口:使用 storyAudioUrl 字段 + cJSON *storyAudioUrl = cJSON_GetObjectItem(data, "storyAudioUrl"); + if (storyAudioUrl && cJSON_IsString(storyAudioUrl)) + { + audio_url = storyAudioUrl->valuestring; + } + } + else + { + // 默认尝试 audioUrl 字段 + cJSON *audioUrl = cJSON_GetObjectItem(data, "audioUrl"); + if (audioUrl && cJSON_IsString(audioUrl)) + { + audio_url = audioUrl->valuestring; + } + } + + // 获取textContent + cJSON *textContent = cJSON_GetObjectItem(data, "textContent"); + char *text_content = NULL; + if (textContent && cJSON_IsString(textContent)) + { + text_content = textContent->valuestring; + } + + // 获取intention + cJSON *intention = cJSON_GetObjectItem(data, "intention"); + char *intention_str = NULL; + if (intention && cJSON_IsString(intention)) + { + intention_str = intention->valuestring; + } + + // 获取storyTheme(故事接口特有) + cJSON *storyTheme = cJSON_GetObjectItem(data, "storyTheme"); + char *story_theme = NULL; + if (storyTheme && cJSON_IsString(storyTheme)) + { + story_theme = storyTheme->valuestring; + } + + // 获取storyContent(故事接口特有) + cJSON *storyContent = cJSON_GetObjectItem(data, "storyContent"); + char *story_content = NULL; + if (storyContent && cJSON_IsString(storyContent)) + { + story_content = storyContent->valuestring; + } + + // 创建推送数据 + cJSON *push_data = cJSON_CreateObject(); + + // 根据接口类型添加相应字段 + if (text_content && strlen(text_content) > 0) + { + cJSON_AddStringToObject(push_data, "textContent", text_content); + } + + if (story_content && strlen(story_content) > 0) + { + cJSON_AddStringToObject(push_data, "storyContent", story_content); + } + + if (intention_str && strlen(intention_str) > 0) + { + cJSON_AddStringToObject(push_data, "intention", intention_str); + } + + if (story_theme && strlen(story_theme) > 0) + { + cJSON_AddStringToObject(push_data, "storyTheme", story_theme); + } + + // 推送到相应的接收线程 + char *json_string = cJSON_Print(push_data); + if (json_string) + { + printf("[JSON_PARSE] pushing data to interface_type=%d: %s\n", interface_type, json_string); + do_ln_http_server_recv_data(interface_type, json_string, strlen(json_string)); + free(json_string); + } + cJSON_Delete(push_data); + + // 下载音频文件 + if (audio_url && strlen(audio_url) > 0) + { + char save_dir[256]; + char filename[128]; + + // 根据接口类型确定保存目录和文件名 + switch (interface_type) + { + case LN_HTTP_SERVICE_CMD_SSE_RECV: + snprintf(save_dir, sizeof(save_dir), "%s/speech", LN_DATA_PATH); + snprintf(filename, sizeof(filename), "speech_audio_%d.mp3", segment_index); + break; + case LN_HTTP_RECV_STORY_CHOOSE: + snprintf(save_dir, sizeof(save_dir), "%s/story/choose", LN_DATA_PATH); + snprintf(filename, sizeof(filename), "story_choose_%d.mp3", segment_index); + break; + case LN_HTTP_RECV_STORY_RANDOM: + snprintf(save_dir, sizeof(save_dir), "%s/story/random", LN_DATA_PATH); + snprintf(filename, sizeof(filename), "story_random_%d.mp3", segment_index); + break; + default: + snprintf(save_dir, sizeof(save_dir), "%s", LN_DATA_PATH); + snprintf(filename, sizeof(filename), "audio_%d.mp3", segment_index); + break; + } + + printf("[JSON_PARSE] found audio URL (interface_type=%d): %s\n", interface_type, audio_url); + printf("[JSON_PARSE] downloading audio to: %s/%s\n", save_dir, filename); + + uint32_t download_result = ln_download_mp3_file_advanced(audio_url, save_dir, filename); + if (download_result == ERRCODE_SUCC) + { + printf("[JSON_PARSE] audio downloaded successfully: %s/%s\n", save_dir, filename); + } + else + { + printf("[JSON_PARSE] audio download failed\n"); + } + } + else + { + printf("[JSON_PARSE] no audio URL found for interface_type=%d, skipping download\n", interface_type); + } + + cJSON_Delete(root); + return ERRCODE_SUCC; +} + +/** + * 从文件中解析data数据,下载MP3并推送消息 + * @param file_path 文件路径 + * @param interface_type 接口类型 + * @return ERRCODE_SUCC 成功,ERRCODE_FAIL 失败 + */ +uint32_t ln_parse_file_and_process_data(const char *file_path, int interface_type) +{ + if (!file_path) + { + printf("[FILE_PARSE] invalid file path\n"); + return ERRCODE_FAIL; + } + + printf("[FILE_PARSE] processing file: %s (interface_type=%d)\n", file_path, interface_type); + + FILE *fp = fopen(file_path, "r"); + if (!fp) + { + printf("[FILE_PARSE] failed to open file: %s\n", file_path); + return ERRCODE_FAIL; + } + + char line[2048]; + int line_count = 0; + int success_count = 0; + int error_count = 0; + + while (fgets(line, sizeof(line), fp)) + { + line_count++; + + // 去除行尾的换行符 + size_t len = strlen(line); + if (len > 0 && line[len - 1] == '\n') + { + line[len - 1] = '\0'; + len--; + } + if (len > 0 && line[len - 1] == '\r') + { + line[len - 1] = '\0'; + len--; + } + + // 跳过空行 + if (len == 0) + { + continue; + } + + // 检查是否是SSE数据格式 (data: {...}) + if (strncmp(line, "data:", 5) == 0) + { + char *json_start = line + 5; + // 跳过可能的空格 + while (*json_start == ' ' || *json_start == '\t') + { + json_start++; + } + + printf("[FILE_PARSE] line %d: processing JSON: %s\n", line_count, json_start); + + uint32_t result = ln_parse_and_process_json_data(json_start, interface_type, line_count); + if (result == ERRCODE_SUCC) + { + success_count++; + } + else + { + error_count++; + } + } + else + { + printf("[FILE_PARSE] line %d: invalid format (not SSE data): %s\n", line_count, line); + error_count++; + } + } + + fclose(fp); + + printf("[FILE_PARSE] processing completed: total lines=%d, success=%d, errors=%d\n", + line_count, success_count, error_count); + + return (error_count == 0) ? ERRCODE_SUCC : ERRCODE_FAIL; +} + diff --git a/code/sdk/zhihui/leiniao/imsl_ln/ln_api.h b/code/sdk/zhihui/leiniao/imsl_ln/ln_api.h index 14966f082..5b927b0b2 100644 --- a/code/sdk/zhihui/leiniao/imsl_ln/ln_api.h +++ b/code/sdk/zhihui/leiniao/imsl_ln/ln_api.h @@ -10,7 +10,7 @@ extern "C" #include "errcode.h" /* HTTP types for helper function prototypes */ #include "netstack/http/include/httpclient.h" - +#include #include "wearable_log.h" typedef struct @@ -45,8 +45,10 @@ typedef struct #define LN_SIGN_SECRET "vDdXVXgPUEUM6_piUjz5AEhMvYTSFzQdPaN-ogKnSus=" /* OTA 包保存目录(最终文件仍将放在 /user/ 下)*/ #define LN_OTA_SAVE_DIR "/user" +/* 用户数据保存目录 */ +#define LN_DATA_PATH "/user/leiniao/" -#define LN_CLIENT_PREPARE_SIZE 4*1024 +#define LN_CLIENT_PREPARE_SIZE 2*1024 #define LN_HTTP_SERVICE_CMD_GET_DEVICE_TOKEN 0x1 /* 获取设备 token */ #define LN_HTTP_SERVICE_CMD_SPEECH_CHAT_STREAM 0x2 /* 语音对话流 */ @@ -154,8 +156,6 @@ extern int g_ln_last_package_size; /* ============================== 公共工具函数(供拆分后的模块复用) ============================== */ HTTPC_RESULT ln_http_prepare_auto(HttpClientData *clientData, int header_size, int resp_size); -void ln_sse_accumulate_and_dispatch(int cmd, const char *data, int data_len, - char *acc, int *acc_len, int acc_size); /* ============================== 各接口函数声明 ============================== */ uint32_t ln_get_device_token_http(const char *device_id, const char *device_iccid, @@ -193,6 +193,9 @@ uint32_t ln_ota_get_task_http(const char *device_id, uint32_t ln_ota_download_flow_http(const char *device_id, const char *device_token, const char *source_version); +/* 文件解析和数据处理函数 */ +uint32_t ln_parse_file_and_process_data(const char *file_path, int interface_type); +uint32_t ln_parse_and_process_json_data(const char *json_data, int interface_type, int segment_index); #ifdef __cplusplus } #endif diff --git a/code/sdk/zhihui/leiniao/imsl_ln/ln_api_speech_chat.c b/code/sdk/zhihui/leiniao/imsl_ln/ln_api_speech_chat.c index 2dbf6f79b..acb6e2f08 100644 --- a/code/sdk/zhihui/leiniao/imsl_ln/ln_api_speech_chat.c +++ b/code/sdk/zhihui/leiniao/imsl_ln/ln_api_speech_chat.c @@ -1,6 +1,8 @@ #include "ln_api.h" #include +#define speech_file_path "/user/leiniao/speech/speech.txt" + /* 读取整个文件到内存,返回缓冲区和长度;需调用者 free */ static uint8_t *ln_read_file_all_speech_chat(const char *path, int *out_len) { @@ -73,6 +75,22 @@ static int ln_build_multipart_form_speech_chat(const char *boundary, return 0; } +static void ln_parse_speech_file(void) +{ + printf("Starting to parse speech file...\n"); + // 处理语音对话数据 + uint32_t result = ln_parse_file_and_process_data(speech_file_path, LN_HTTP_SERVICE_CMD_SSE_RECV); + + if (result == ERRCODE_SUCC) + { + printf("Speech file parsing completed successfully!\n"); + } + else + { + printf("Speech file parsing failed!\n"); + } +} + uint32_t ln_speech_chat_stream_http(const char *device_id, const char *device_token, const char *audio_path) @@ -174,6 +192,24 @@ uint32_t ln_speech_chat_stream_http(const char *device_id, return ret; } + // 打开文件准备写入 + const char *save_path_folder = "/user/leiniao/speech/"; + // 判断目录是否存在,不存在则创建 + if (access(save_path_folder, F_OK) == -1) + { + uapi_create_folder(save_path_folder); + } + FILE *fd = fopen(speech_file_path, "w+"); + if (!fd) + { + printf("chatstream: fopen failed for %s:\n", speech_file_path); + free(body); + HttpClientClse(&client); + HttpClientUnprepare(&clientData); + return ERRCODE_FAIL; + } + + size_t total_written = 0; do { HTTPC_RESULT recv_res = HttpClientRecvResponse(&client, &clientData); @@ -183,8 +219,19 @@ uint32_t ln_speech_chat_stream_http(const char *device_id, // 处理接收到的数据块 if (clientData.contentBlockLen > 0) { - do_ln_http_server_recv_data(LN_HTTP_SERVICE_CMD_SSE_RECV, - clientData.responseBuf, clientData.contentBlockLen); + // 写入到文件(保持原有功能) + size_t written = fwrite(clientData.responseBuf, 1, clientData.contentBlockLen, fd); + if (written != clientData.contentBlockLen) + { + printf("chatstream: write failed, written=%zu, expected=%d\n", + written, clientData.contentBlockLen); + free(body); + HttpClientClse(&client); + HttpClientUnprepare(&clientData); + fclose(fd); + return ERRCODE_FAIL; + } + total_written += written; } } else @@ -197,5 +244,12 @@ uint32_t ln_speech_chat_stream_http(const char *device_id, HttpClientUnprepare(&clientData); HttpClientClse(&client); free(body); + fclose(fd); + + if (total_written > 0) + { + ln_parse_speech_file(); + } + return ERRCODE_SUCC; } diff --git a/code/sdk/zhihui/leiniao/imsl_ln/ln_api_story_choose.c b/code/sdk/zhihui/leiniao/imsl_ln/ln_api_story_choose.c index 056d414f9..b6c364ec1 100644 --- a/code/sdk/zhihui/leiniao/imsl_ln/ln_api_story_choose.c +++ b/code/sdk/zhihui/leiniao/imsl_ln/ln_api_story_choose.c @@ -1,6 +1,8 @@ #include "ln_api.h" #include +#define story_choose_file_path "/user/leiniao/story/choose.txt" + /* 读取整个文件到内存,返回缓冲区和长度;需调用者 free */ static uint8_t *ln_read_file_all_story(const char *path, int *out_len) { @@ -73,6 +75,22 @@ static int ln_build_multipart_form_story(const char *boundary, return 0; } +static void ln_parse_story_file(void) +{ + printf("Starting to parse story choose file...\n"); + + uint32_t result = ln_parse_file_and_process_data(story_choose_file_path, LN_HTTP_RECV_STORY_CHOOSE); + + if (result == ERRCODE_SUCC) + { + printf("Story file parsing completed successfully!\n"); + } + else + { + printf("Story file parsing failed!\n"); + } +} + uint32_t ln_story_choose_http(const char *device_id, const char *device_token, const char *audio_path) @@ -143,7 +161,7 @@ uint32_t ln_story_choose_http(const char *device_id, printf("story choose header_buf: %s\n", header_buf); /* 自适应准备 HTTP 缓冲区 */ - HTTPC_RESULT prep = ln_http_prepare_auto(&clientData, 2048, LN_CLIENT_PREPARE_SIZE); + HTTPC_RESULT prep = ln_http_prepare_auto(&clientData, 2048, LN_CLIENT_PREPARE_SIZE * 2); if (prep != HTTP_SUCCESS) { printf("story choose: prepare HTTP buffer failed (code: %d)\n", prep); @@ -178,6 +196,24 @@ uint32_t ln_story_choose_http(const char *device_id, return ret; } + // 打开文件准备写入 + const char *save_path_folder = "/user/leiniao/story/"; + // 判断目录是否存在,不存在则创建 + if (access(save_path_folder, F_OK) == -1) + { + uapi_create_folder(save_path_folder); + } + FILE *fd = fopen(story_choose_file_path, "w+"); + if (!fd) + { + printf("story choose: fopen failed for %s:\n", story_choose_file_path); + free(body); + HttpClientClse(&client); + HttpClientUnprepare(&clientData); + return ERRCODE_FAIL; + } + + size_t total_written = 0; do { HTTPC_RESULT recv_res = HttpClientRecvResponse(&client, &clientData); @@ -187,8 +223,18 @@ uint32_t ln_story_choose_http(const char *device_id, // 处理接收到的数据块 if (clientData.contentBlockLen > 0) { - do_ln_http_server_recv_data(LN_HTTP_RECV_STORY_CHOOSE, - clientData.responseBuf, clientData.contentBlockLen); + size_t written = fwrite(clientData.responseBuf, 1, clientData.contentBlockLen, fd); + if (written != clientData.contentBlockLen) + { + printf("story choose: write failed, written=%zu, expected=%d\n", + written, clientData.contentBlockLen); + free(body); + HttpClientClse(&client); + HttpClientUnprepare(&clientData); + fclose(fd); + return ERRCODE_FAIL; + } + total_written += written; } } else @@ -201,5 +247,12 @@ uint32_t ln_story_choose_http(const char *device_id, free(body); HttpClientUnprepare(&clientData); HttpClientClse(&client); + fclose(fd); + + if (total_written > 0) + { + ln_parse_story_file(); + } + return ERRCODE_SUCC; } -- 2.17.1