ESP32 idf websocket 讯飞星火大模型 接入
AI生成摘要:本文介绍了使用讯飞星火大模型API的实践过程,重点解决WiFi连接、WebSocket配置和鉴权问题。首先通过ESP32的WiFi例程实现网络连接,然后配置并测试乐鑫的WebSocket客户端。针对讯飞API的鉴权需求,详细说明了获取网络时间和生成GMT格式时间字符串的方法,提供了C语言实现代码。作者分享了调试过程中遇到的典型问题及解决方案,为开发者实现类似功能提供了实用参考。
提示:文章写完后,目录可以自动生成,如何生成可参考右边的帮助文档
文章目录
前言:为什么要用讯飞星火大模型
按理来说其他其他平台也有差不多的API,我猜应该也差不多的处理方式,所以应该可以借鉴一下
没啥,单纯就是没有找到其他免费试用的哈哈哈哈哈。
控制台:https://console.xfyun.cn/services/bm35
如果是用Spark Lite的话,反正写的是token无限制
我注册的时候,他送了我X1模型几百万的token,听他意思好像是跟DS差不多的水平,但我其实最终只是想做成类似于语音对话或者是控制硬件的形式,所以X1这个对我来说倒没有太多的必要【插一句题外话,X1是会把推理的那些内容给传下来的,但如果我只是作为对话形式的话,其实我真的很建议官方能够在文档里面说一下有没有相关的配置】
好了,下面就是正文部分了
(我的idf环境版本:V5.4.0,应该用5.0.0以上都没有很大的区别)
1、实现wifi功能
1.1 先烧个例程
websocket嘛,既然带着web所以肯定是要联网的,而且乐鑫的ESP32他的优势也正是联网(不然我拿个STM32不好嘛),所以先把wifi的例程先给跑通。
一般来说选择好端口,选择好,选好芯片型号就没啥问题的,先烧录进去瞅一眼。
如果烧录有问题,具体看提示什么问题,比如说:
- 芯片型号不对,提示什么Not esp32c3 but esp32s3的,具体我也忘了
==>就是我说的芯片型号没选好,选回来就行; - 串口连接不上
这个问题,有点复杂,可能跟以下原因有关:
==>某个IO没有上拉,对于ESP32C3来说,需要上拉IO08(供应商说的,如果不上拉大概率下载不了程序),好像ESP32S3也行,其他的就需要根据技术规格书来确定了;
==>接线的问题,EN跟BOOT这两个应该一般人都不会错,TX/RX反接试一下;
==>下载的时候其实是需要EN跟BOOT进行下拉的,这其实跟硬件有关系,但我现在偷懒了,不画按键了,直接买那个带EN和BOOT脚的下载器,一步到位。 - 其他就百度CSDN啦。。。。
1.2 配置wifi信息
第一次看到这玩意的时候,我是挺懵的,主要是我也不知道他官方的wifi账号密码是啥
直接给他改了就行,把他改成你家的wifi名和wifi密码就可以了;
不想用宏,用变量去管理的话,在这里改也行(因为我是有配网的要求的,所以我是用blufi来管理这个账号密码的,后面有时间我也可能会写(如果…))
2、测试一下乐鑫的websocket
这里需要关注一下自己用的esp idf的版本了
好像是V5.0.0以上,乐鑫自带的环境就没有自带websocket client了,
所以需要自己去准备一下
1、可以用idf.py add-dependency的方式(参考链接);
2、直接下载到本地,然后自己写cmakelists去调用,下载链接,建议先跑通esp_websocket_client的例程再说
所以我的组件是这样子的:
base里面放的就是一些通用件,例如flash、wifi信息、个人配置;
xunfei里面就是放了esp_websocket_client,还有就是我自己调用esp_websocket_client后写的一些函数
经过测试,顶层的cmakelists需要这么增加路径:记得记得,先编译一下程序,把程序下载到板子里面测试一下!
3、关于讯飞星火大模型的那些事
开始之前先吐槽一下,他的官方文档里面写得真的很不小白!
我调了一下午,始终说我鉴权失败,后面发现问题出在哪里了,所以我才想着写这个记录一下
一共分以下步骤:
第一步:生成鉴权url;
第二步,生成请求体;
第三步,发送并接受流式信息,组包,最终打印。
下面思路:先来看看官方的python程序做了啥,然后分析C语言要做什么,然后再写(chao)代码。
3.1.鉴权url
3.1.1 date参数
官方示例:
from datetime import datetime
from time import mktime
from wsgiref.handlers import format_date_time
cur_time = datetime.now()
date = format_date_time(mktime(cur_time.timetuple()))
# 假使生成的date和下方使用的date = Fri, 05 May 2023 10:43:39 GMT
因此我们可以看到,在这里我们要做什么东西:
1、获取当前时间;
2、生成指定格式的时间格式。
3.1.1.1 获取网络授时
时间偏差不能大于300s,所以要不就是用rtc+电池的方式,要不就是网络授时,所以我选择网络授时,其实乐鑫也有sntp的例程,根据idf版本修改一下就行,网上有些idf的版本好旧,所以有些函数是报错的,需要自己改一下
其实下面这个我也是网上借鉴一点,然后DS一点改出来的,所以可以参考一下
#include "lwip/ip_addr.h"
#include "esp_sntp.h"
static void obtain_time(void)
{
ESP_LOGI(TAG, "obtain_time");
// 添加 SNTP 服务状态检查
if (sntp_get_sync_status() != SNTP_SYNC_STATUS_COMPLETED)
{
esp_sntp_stop();
esp_sntp_setoperatingmode(SNTP_OPMODE_POLL);
esp_sntp_setservername(0, "cn.pool.ntp.org"); // 授时网址,看着写就行
esp_sntp_setservername(1, "ntp1.aliyun.com");
esp_sntp_init();
}
setenv("TZ", "CST-8", 1); // 设置时区
tzset(); // 好像是时区应用
time_t now = 0;
struct tm timeinfo = {0};
// 添加时间同步等待(最多等待 10 秒)
int retry = 0;
const int retry_count = 20;
while (sntp_get_sync_status() != SNTP_SYNC_STATUS_COMPLETED && ++retry < retry_count)
{
ESP_LOGI(TAG, "Waiting for system time sync (%.1d/%.1d)...", retry, retry_count);
vTaskDelay(500 / portTICK_PERIOD_MS);
}
time(&now);
localtime_r(&now, &timeinfo); // 准备打印一下时间看看
// 添加时间有效性检查
if (timeinfo.tm_year < (2024 - 1900))
{
ESP_LOGE(TAG, "Failed to obtain valid time");
return;
}
}
3.1.1.2 按照要求格式生成时间字符串
注意一下,他是GMT时间,也就是格林威治时间,不要转化成当前时区时间就行
另外,为了方便调试,我按照他给的例程写了特殊的时间,这个时间跟官方的时间一模一样的,方便自己对比
void create_auth_date(char *output, uint8_t output_len)
{
#if 1 // 实时时间
/* 生成鉴权日期 */
time_t now = 0;
struct tm timeinfo = {0};
time(&now); // 获取当前时间
// localtime_r(&now, &timeinfo); // 获取当前时区时间,千万不要加这个!!!
gmtime_r(&now, &timeinfo); // GMT时间
strftime(output, output_len, "%a, %d %b %Y %H:%M:%S GMT", &timeinfo);
#else
struct tm timeinfo = {0};
timeinfo.tm_year = 2023 - 1900;
timeinfo.tm_mon = 5 - 1;
timeinfo.tm_mday = 5;
timeinfo.tm_hour = 10;
timeinfo.tm_min = 43;
timeinfo.tm_sec = 39;
timeinfo.tm_wday = 5;
strftime(output, output_len, "%a, %d %b %Y %H:%M:%S GMT", &timeinfo);
#endif
}
3.1.2 authorization参数
3.1.2.1 字符串
首先,按照官方的格式,先生成这个格式的字符串
host: spark-api.xf-yun.com
date: Fri, 05 May 2023 10:43:39 GMT
GET /v1.1/chat HTTP/1.1
换行符、空格之类的要一模一样,建议最好是在后面加密的时候校验一下(下面会有加密校验的)
然后我说他最坑的地方来了,他就提了一句:利用上方的date动态拼接生成字符串tmp,这里以星火url为例,实际使用需要根据具体的请求url替换host和path
host指的是spark-api.xf-yun.com,
那么path呢,要改啥,嗯,没写(当时候我确实也没有很详细去研究),然后一直就鉴权错误了
path指的是GET后面的“/v1.1/chat”,这个也要跟着改
我直接这么写了,亲测只要改宏,就可以无缝衔接X1和Lite
#ifdef X1_MODEL
#define URL_ROOT_HOST "spark-api.xf-yun.com"
#define URL_PATH "/v1/x1"
#else
#define URL_ROOT_HOST "spark-api.xf-yun.com"
#define URL_PATH "/v1.1/chat"
#endif
snprintf(output, output_len,
"host: %s\ndate: %s\nGET %s HTTP/1.1",
URL_ROOT_HOST,
date, // 3.1.1生成的时间字符串
URL_PATH);
3.1.2.2 hmac-sha256算法加密
这里我确实不是很懂,所以我直接丢给DS了,边看边改;
跟着官方的用例做就行了,然后用乐鑫的mbedtls库。
我个人建议是不要直接复制DS的代码,最好是手敲一下,我是很怕突然报一大堆错的,我还是比较喜欢vscode的自动补全
然后注意一下函数之间的参数长度的空间够不够,DS这方面会考虑到的
(可能需要自己调整一下,我也是一开始跑到这个位置就崩,后面发现就是空间不够模块就重启了)
这里麻烦自己把官方的代码扔进去DS生成哈然后一定要拿官方的示例用的配置进行生成,看一下自己代码生成的结果跟官方的是不是一模一样!!!
3.1.3 生成最终的url
没啥好像的,就是把3.1.2.2生成的结果跟域名拼在一起就行了,这里需要注意一下,空格和加号要换成特定的符号,如下:
static esp_err_t url_encode(const char *src, char *dst, size_t dst_len)
{
const char hex[] = "0123456789ABCDEF";
size_t i, j = 0;
for (i = 0; src[i] && j < dst_len - 1; i++)
{
// 添加空格转+号处理
if (src[i] == ' ')
{
dst[j++] = '+';
continue;
}
if (isalnum((unsigned char)src[i]) || src[i] == '-' || src[i] == '_' || src[i] == '.' || src[i] == '~')
{
dst[j++] = src[i];
}
else
{
if (j + 3 >= dst_len)
break;
dst[j++] = '%';
dst[j++] = hex[(unsigned char)src[i] >> 4];
dst[j++] = hex[(unsigned char)src[i] & 0x0F];
}
}
// 添加缓冲区空间检查
if (src[i] != '\0')
{ // 如果循环因缓冲区空间退出
dst[0] = '\0';
return ESP_ERR_INVALID_SIZE;
}
dst[j] = '\0';
return ESP_OK;
}
如果不知道我这代码是啥意思,建议先拼在一起,然后把目标url跟你生成的url都一起丢给DS,然后叫他给你写个函数,估计即使我上面这个函数了
一定要拿官方的示例对比一下,看一下自己代码生成的结果跟官方的是不是一模一样!!!
另外,建议拿postman测一下url对不对,具体可以看这个,看第五个怎么用postman测试一下就行
链接
3.2 请求体
这个就是拼json了,看看自己用的模型示例跟着需求改就行,如下:
char *create_request_body(const char *user_query)
{
cJSON *root = cJSON_CreateObject(); // 根节点
if (!root)
{
ESP_LOGE(TAG, "cJSON_CreateObject failed");
return NULL;
}
/* header 相关 */
cJSON *header = cJSON_CreateObject();
cJSON_AddStringToObject(header, "app_id", APPID);
cJSON_AddStringToObject(header, "uid", USER_ID);
cJSON_AddItemToObject(root, "header", header);
/* parameter 相关 */
cJSON *parameter = cJSON_CreateObject();
cJSON *chat = cJSON_CreateObject();
cJSON_AddStringToObject(chat, "domain", "lite"); // 指定访问的模型版本
cJSON_AddNumberToObject(chat, "temperature", 0.1); // 核采样阈值。取值越高随机性越强,即相同的问题得到的不同答案的可能性越大,取值范围 (0,1] ,默认值0.5
cJSON_AddNumberToObject(chat, "max_tokens", 1024); // 模型回答的tokens的最大长度,Lite取值为[1,4096],默认为4096
/* 创建tools数组 */
cJSON *tools = cJSON_CreateArray();
cJSON *web_search_tool = cJSON_CreateObject();
cJSON_AddStringToObject(web_search_tool, "type", "web_search"); // 工具类型 web_search : 网页搜索
cJSON *web_search_params = cJSON_CreateObject();
cJSON_AddBoolToObject(web_search_params, "enable", false); // 是否开启网络搜索
cJSON_AddBoolToObject(web_search_params, "show_ref_label", false); // 开关控制触发联网搜索时是否返回信源信息
cJSON_AddStringToObject(web_search_params, "search_mode", "normal"); // deep:深度搜索,信源内容更详细丰富,prompt token会膨胀 normal:简要搜索
cJSON_AddItemToObject(web_search_tool, "web_search", web_search_params);
cJSON_AddItemToArray(tools, web_search_tool);
cJSON_AddItemToObject(chat, "tools", tools);
cJSON_AddItemToObject(parameter, "chat", chat);
cJSON_AddItemToObject(root, "parameter", parameter);
/* payload 相关 */
cJSON *payload = cJSON_CreateObject();
cJSON_AddItemToObject(root, "payload", payload);
cJSON *message = cJSON_CreateObject();
cJSON_AddItemToObject(payload, "message", message);
cJSON *text = cJSON_CreateArray();
cJSON_AddItemToObject(message, "text", text);
cJSON *text_item = cJSON_CreateObject();
cJSON_AddStringToObject(text_item, "role", "user");
cJSON_AddStringToObject(text_item, "content", user_query);
cJSON_AddItemToArray(text, text_item);
char *result = cJSON_Print(root);
return result;
}
3.3 初始化websocket那些东西
参数配置这么写就行,其他跟着官方例程走就好了,超时时间别太小就行
esp_websocket_client_config_t config = {
.uri = auth_url, // 连接地址
.network_timeout_ms = 60000, // 连接超时时间
.disable_auto_reconnect = true, // 不自动重连
.reconnect_timeout_ms = 50000, // 重连超时时间
.keep_alive_enable = true, // 开启 keep-alive
.keep_alive_idle = 20, // 空闲时间,秒
.keep_alive_interval = 5, // 每个x秒发送一次ping
.keep_alive_count = 5, // 发送x次ping后没有响应,断开连接
};
3.4 把请求体发送到url
其实就一句话
static void send_chat_request(void)
{
char *json_str = create_request_body("推荐两个适合自驾春游的景点");
esp_websocket_client_send_text(client, json_str, strlen(json_str), portMAX_DELAY);
// 记得释放内存,避免你直接抄,我在这里埋个坑哈哈哈哈哈哈
}
3.5 返回结果的数据流拼在一起
根据程序来看呢,他是通过op_code来判断数据流有没有结束的
if (data->op_code == 0x2)
{
// Opcode 0x2 indicates binary data
ESP_LOG_BUFFER_HEX("Received binary data", data->data_ptr, data->data_len);
}
else if (data->op_code == 0x08 && data->data_len == 2)
{
ESP_LOGW(TAG, "Received closed message with code=%d", 256 * data->data_ptr[0] + data->data_ptr[1]);
}
else
{
ESP_LOGW(TAG, "Received=%.*s\n\n", data->data_len, (char *)data->data_ptr);
}
他的结果给给你发很多东西的,实际上你只需要下面这些内容,也是一样,把结果丢给DS,让他给你生成就行
// 处理payload
cJSON *payload = cJSON_GetObjectItem(root, "payload");
if (payload)
{
// 处理choices
cJSON *choices = cJSON_GetObjectItem(payload, "choices");
if (choices)
{
cJSON *text = cJSON_GetObjectItem(choices, "text");
if (text && cJSON_IsArray(text))
{
int array_size = cJSON_GetArraySize(text);
for (int i = 0; i < array_size; i++)
{
cJSON *text_item = cJSON_GetArrayItem(text, i);
if (text_item)
{
// 处理推理内容
cJSON *reasoning = cJSON_GetObjectItem(text_item, "reasoning_content");
if (reasoning && reasoning->valuestring)
{
// 追加到缓冲区
strncat(current_response.reasoning, reasoning->valuestring,
sizeof(current_response.reasoning) - strlen(current_response.reasoning) - 1);
// ESP_LOGI(TAG, "推理: %s", reasoning->valuestring);
}
// 处理最终内容
cJSON *content = cJSON_GetObjectItem(text_item, "content");
if (content && content->valuestring)
{
// 追加到缓冲区
strncat(current_response.content, content->valuestring,
sizeof(current_response.content) - strlen(current_response.content) - 1);
// ESP_LOGI(TAG, "内容: %s", content->valuestring);
}
// 获取序列号
cJSON *index = cJSON_GetObjectItem(text_item, "index");
if (index)
{
current_response.seq = index->valueint;
}
}
}
}
// 获取choices状态
cJSON *choices_status = cJSON_GetObjectItem(choices, "status");
if (choices_status)
{
// 如果choices状态为2,表示内容已完整
if (choices_status->valueint == 2)
{
ESP_LOGI(TAG, "内容完整接收");
ESP_LOGI(TAG, "%s", current_response.content);
}
}
}
// 处理使用情况
cJSON *usage = cJSON_GetObjectItem(payload, "usage");
if (usage)
{
cJSON *text_usage = cJSON_GetObjectItem(usage, "text");
if (text_usage)
{
cJSON *total_tokens = cJSON_GetObjectItem(text_usage, "total_tokens");
if (total_tokens)
{
current_response.total_tokens = total_tokens->valueint;
}
}
}
}
总结
目前就做到了发问跟把结果放一起,后续估计会往下做:
1、基于乐鑫的tts进行语音播报;
2、看看怎么用用大模型来控制硬件。
好了,有缘下次见
更多推荐
所有评论(0)