聆思大模型 AI 开发套件(CSK6-MIX)不仅提供了完善的多模态大模型调用功能,还提供了丰富的本地外设接口,可以通过端侧结合大模型来实现很多非常有趣的功能。
这篇分享,就是基于CSK6-MIX开发套件,结合外部设备,实现智能电子积木平台的功能。
在实现该智能电子积木平台的过程中,得到了聆思官方王志彬、赵卓斌、叶康、朱元恒等多位技术大佬手把手的指导,并不厌其烦的回答我的疑问,也得到了CSK开发小助手小慧姐姐的支持,在此表示特别特别的感谢!!!
功能概览
在这个智能电子积木平台上,通过串口,连接到积木块探测模块,获取当前连接的积木功能块编号,然后将该编号发送到云端,云端根据当前对应的功能设置,调用积木块编号对应的处理逻辑,处理完毕后,返回结果到智能电子积木平台,进行语音播放或者功能执行。
前期调研
- 参考案例
聆思官方为CSK6-MIX提供了多种案例,经过实际使用和了解,使用多模态案例中的LLM_control座位基础,进行二次开发 Zephyr设备树(devicetree)定义
实现这个智能电子积木平台,首先需要了解设备端也就是开发板的开发。
CSK6-MIX上的运行环境是基于Zephyr RTOS 作为操作系统,所以需要对Zephyr有一些了解,后面的开发中,会涉及到Zephyr的devicetree定义GPIO端口功能,其中包括串口和按键。
通过SDK中csk的board定义中的csk6-pinctrl.h,可以得知GPIOA_08、GPIO_09可用于uart1:
从聆思官方提供的《多模态开发套件硬件原理图 V1.1》可以得知GPIOA_08、GPIO_09可以使用:
从原理图中,也可以得知CSK6直连的K3按键,连接到了GPIOB_00:
- 外设驱动开发
在这个智能电子积木平台的开发中,涉及到按键和UART的处理,因此需要了解官方文档外设驱动中的:
a. GPIO
b. UART - 端云交互链路协议开发
因为涉及到将语音以外的自定义用户数据(此处为积木功能块编号)发送到云端进行处理,所以需要了解官方文档中的 端云交互链路协议开发。 - 在线编排
聆思大模型平台LSPlatform提供了基于node-red的在线编排的功能,可以通过拖拽和预定于功能块的低代码方式,进行多模态大模型功能的调用,对于实际的开发工作非常的友好,大大提高了开发的效率。
要了解在线编排,可以查看:https://docs2.listenai.com/x/ONjKxNdxZ
硬件准备
硬件设备
- 聆思大模型 AI 开发套件(CSK6-MIX)
- ESP32-C3
- 原始积木
其中,ESP32-C3有3块,一块做为积木块探测模块使用,另外两块做为积木功能块使用。
硬件制作和连接
- 积木块探测模块连接:
积木块需要使用串口,连接到CSK6-MIX开发板,并通过无线方式探测积木块。
根据前面的了解,将积木块探测模块UART的TX连接到GPIOA_09,RX连接到GPIOA_08。 - 积木块
将ESP32-C3做好连线,安装到积木块的内容
功能开发
AIUI组件修改
要实现自定义的数据发送到服务端,需要在AIUI组件中,添加对应的函数,具体操作如下:
打开文件:components/aiui_inter_conn/aiui_conn.c,添加如下内容:
int aiui_send_custom_data(aiui_handle_t handle, int block_id)
{
aiui_inter_handle_t *aiui_handle = handle;
int ret = 0;
char *start_format =
"{\"action\":\"start\",\"params\":{\"features\":[\"nlu\",\"tts\"],\"data_type\":"
"\"text\",\"aue\":\"raw\",\"nlu_properties\":{\"abilities\":[{\"name\":\"alarm\","
"\"intents\":[\"create\",\"cancel\"]}],\"sn\":\"%s\",\"lat\":\"113."
"94733033692522\",\"lng\":\"22.53629851362625\",\"custom\":{\"block_id\":%d}}}}";
#define MAX_SEND_DATA_SIZE 512
char send_data[MAX_SEND_DATA_SIZE];
// 使用 sprintf 函数将 block_id 插入到字符串中
snprintf(send_data, MAX_SEND_DATA_SIZE, start_format, m_device_id, block_id);
ret = csk_ws_client_send_text(aiui_handle->ws_handle, send_data);
if (ret < 0) {
LOG_ERR("%s: %d, ret = %d", __FILE__, __LINE__, ret);
return -1;
}
uint8_t prompt[] = "发送用户数据";
ret = csk_ws_client_send_bin(aiui_handle->ws_handle, prompt, sizeof(prompt));
if (ret) {
LOG_ERR("[%s] failed, err:%d", __FUNCTION__, ret);
return -1;
}
LOG_INF("func: %s, line: %d", __FUNCTION__, __LINE__);
return ret;
}
在该函数中,通过调用csk_ws_client_send_text,发送自定义的用户数据到云端。
其具体的调用逻辑,在 端云交互链路协议开发 中有详细的说明:
设备树修改
在 apps/LLM_control/boards/csk6_duomotai_devkit.overlay 中,已经提供了核心的设备树定义,我们只需要在此基础上,添加UART和按键的定义即可,具体如下.
按键定义
/ { aliases { sw0 = &user_button; }; gpio_keys { compatible = "gpio-keys"; user_button: button { label = "User"; gpios = <&gpiob 00 GPIO_ACTIVE_LOW>; }; }; };
UART1定义:
&pinctrl{ pinctrl_uart1_rx_default: uart1_rx_default{ pinctrls = <UART1_RXD_GPIOA_09>; //rx pin }; pinctrl_uart1_tx_default: uart1_tx_default{ pinctrls = <UART1_TXD_GPIOA_08>; //tx pin }; pinctrl_uart1_tx_default: uart1_tx_default{ pinctrls = <UART1_TXD_GPIOA_08>; //tx pin }; }; &uart1 { pinctrl-0 = <&pinctrl_uart1_rx_default &pinctrl_uart1_tx_default>; pinctrl-names = "default"; current-speed = <115200>; status = "okay"; };
串口功能处理
积木块探测模块一旦探测到积木块连接后,就会通过串口输出信息:info,积木块功能块硬件信息,编号
,例如:info,11:22:33:44:55:66,1
在CSK6-MIX上,只需要接受该信息,并进行分析,获取最后的编号即可。
添加一个新的文件:apps/LLM_control/src/app_ui/uart_indication.c,内容如下:
#include <zephyr/kernel.h>
#include <zephyr/device.h>
#include <zephyr/sys/printk.h>
#include <string.h>
#include <zephyr/devicetree.h>
#include <zephyr/logging/log.h>
#include <zephyr/drivers/uart.h>
#include <stdio.h>
LOG_MODULE_REGISTER(uart_indication, LOG_LEVEL_INF);
/*通过uart设备树label获取nodeid*/
#define UARTX DT_NODELABEL(uart1)
/* 获取uart设备实例 */
const struct device *uart = DEVICE_DT_GET(UARTX);
#define MSG_SIZE 32
/* receive buffer used in UART ISR callback */
static char rx_buf[MSG_SIZE];
static int rx_buf_pos;
int block_id = 0;
/* queue to store up to 10 messages (aligned to 4-byte boundary) */
K_MSGQ_DEFINE(uart_msgq, MSG_SIZE, 10, 4);
/*
* Read characters from UART until line end is detected. Afterwards push the
* data to the message queue.
*/
void serial_cb(const struct device *dev, void *user_data)
{
uint8_t c;
if (!uart_irq_update(uart)) {
return;
}
if (!uart_irq_rx_ready(uart)) {
return;
}
/* read until FIFO empty */
while (uart_fifo_read(uart, &c, 1) == 1) {
if ((c == '\n' || c == '\r') && rx_buf_pos > 0) {
/* terminate string */
rx_buf[rx_buf_pos] = '\0';
/* if queue is full, message is silently dropped */
k_msgq_put(&uart_msgq, &rx_buf, K_NO_WAIT);
/* reset the buffer (it was copied to the msgq) */
rx_buf_pos = 0;
} else if (rx_buf_pos < (sizeof(rx_buf) - 1)) {
rx_buf[rx_buf_pos++] = c;
}
/* else: characters beyond buffer size are dropped */
}
}
static int uart_init(void)
{
if(!device_is_ready(uart)){
printk("[uart]device:%s is not ready!", uart->name);
return -1;
}
return 0;
}
void uart_task(void *p1, void *p2, void *p3)
{
char tx_buf[MSG_SIZE];
if(uart_init()){
return;
}
printk("[uart]devices %s is ready", uart->name);
/* configure interrupt and callback to receive data */
int ret = uart_irq_callback_user_data_set(uart, serial_cb, NULL);
if (ret < 0) {
if (ret == -ENOTSUP) {
printk("Interrupt-driven UART API support not enabled\n");
} else if (ret == -ENOSYS) {
printk("UART device does not support interrupt-driven API\n");![image.png](/img/bVb4Vt)
} else {
printk("Error setting UART callback: %d\n", ret);
}
return;
}
uart_irq_rx_enable(uart);
printk("Start to check block\r\n");
/* indefinitely wait for input from the user */
while (k_msgq_get(&uart_msgq, &tx_buf, K_FOREVER) == 0) {
printk("Echo: ");
printk("%s", tx_buf);
printk("\r\n");
// 检查tx_buf是否以info开头
if (strncmp(tx_buf, "info", 4) == 0) {
// 如果是info开头
// 查找最后一个逗号的位置
int last_comma_pos = strrchr(tx_buf, ',') - tx_buf;
// 确保字符串符合预期格式(包含至少两个逗号)
if (last_comma_pos > 4 && tx_buf[4] == ',') {
// 提取整数部分
char *num_str = tx_buf + last_comma_pos + 1;
// 将字符串转换为整数并存入block_id
block_id = atoi(num_str);
} else {
printf("Error: tx_buf does not match the expected format.\n");
block_id = -1; // 或者使用其他默认值或错误标识
}
} else {
// 如果不是info开头,发送block_id
block_id = 0;
}
printk("set block_id to %d\n", block_id);
}
return;
}
K_THREAD_DEFINE(uart_task_id, 2048, uart_task, NULL, NULL, NULL, 1, 0, 1000);
在该文件中,定义了一个全局变量 block_id, 一旦通过串口检测到对应的功能模块编号,就会设置编号到block_id。
在这个文件的最后,使用 K_THREAD_DEFINE 宏定义,使用多任务的方式运行串口检测功能。
在apps/LLM_control/src/main.c文件中,直刷要添加 extern int block_id;
,即可引用该变量了。
主逻辑处理
在 apps/LLM_control/src/main.c ,进行核心功能的处理。为了方便进行测试,使用按键K3按下时,发送检测到的积木功能块编号到云端。
在 main.c文件的头部合适位置,添加下面的头文件调用和预定义:
#include <zephyr/drivers/gpio.h>
#include <inttypes.h>
#define SW0_NODE DT_ALIAS(sw0)
static const struct gpio_dt_spec button = GPIO_DT_SPEC_GET_OR(SW0_NODE, gpios,
{0});
static struct gpio_callback button_cb_data;
void button_pressed(const struct device *dev, struct gpio_callback *cb,
uint32_t pins)
{
printk("Button pressed at %" PRIu32 "\n", k_cycle_get_32());
}
然后在main()中,添加按键初始化:
int main(void)
{
///...
if (!gpio_is_ready_dt(&button)) {
printk("Error: button device %s is not ready\n",
button.port->name);
return 0;
}
ret = gpio_pin_configure_dt(&button, GPIO_INPUT);
if (ret != 0) {
printk("Error %d: failed to configure %s pin %d\n",
ret, button.port->name, button.pin);
return 0;
}
ret = gpio_pin_interrupt_configure_dt(&button,
GPIO_INT_EDGE_TO_ACTIVE);
if (ret != 0) {
printk("Error %d: failed to configure interrupt on %s pin %d\n",
ret, button.port->name, button.pin);
return 0;
}
gpio_init_callback(&button_cb_data, button_pressed, BIT(button.pin));
gpio_add_callback(button.port, &button_cb_data);
printk("Set up button at %s pin %d\n", button.port->name, button.pin);
///...
}
上述部分为初始化按键对应的GPIO,设置回调。
检测按键的功能,可以在回调中进行,也可以在循环中进行检测。
在循环中检测按键的代码如下:
int prev_val = -1;
while (1) {
// ping_work_handler(NULL);
int val = gpio_pin_get_dt(&button);
if(val>=0 && val!=prev_val) {
prev_val = val;
printk("button val is %d\n", val);
if(val>0) {
printk("block_id is %d\n", block_id);
ui_show_block_id(block_id);
ret = aiui_send_custom_data(aiui_handle, block_id);
if(ret<0) {
printk("aiui_send_custom_data error: %d\n", ret);
} else {
printk("aiui_send_custom_data success: %d\n", ret);
}
}
}
k_msleep(1);
}
在上述代码中,一旦检测到按键状态与上次不同,则表示按键按下或者松开了,这样可以防止连续按键触发。
当检测到按下时,就调用 aiui_send_custom_data() 发送block_id到云端。
在main.c的 static void consumer_thread(void)调用中,还有接收到云端返回后的处理,在 bgColor
处理后,添加如下的代码即可:
}else if(!strcmp(service->valuestring, "setBlock")){
cJSON *data = cJSON_GetObjectItem(intent, "data");
if(data != NULL){
cJSON *result = cJSON_GetObjectItem(data, "result");
if(result != NULL){
// int size = cJSON_GetArraySize(result);
cJSON *item = cJSON_GetArrayItem(result, 0);
cJSON *type = cJSON_GetObjectItem(item, "type");
cJSON *value = cJSON_GetObjectItem(item, "value");
LOG_INF("service=%s: type=%s, value=%s",
service->valuestring,
type->valuestring,
value->valuestring);
int block_id_get = 0;
sscanf(value->valuestring, "%d", &block_id_get);
LOG_INF("block_id value: %x", block_id_get);
}
}
}else {
LOG_ERR("Unknown service: %s", service->valuestring);
}
该部分仅用于输出信息即可,目前没有做特殊的处理。
在线编排
关于设备接入和大模型基础开发的部分,这里就不专门说了,大家可以看官方文档:[如何搭建大模型智能硬件](https://docs2.listenai.com/x/...)
- 建立一个多模态应用:
- 在产品设置中关联
设置提示词模版:
通用:设置背景颜色:
编号处理:
进入在线编排
a. 落域处理:
在落域处理中,添加对用户数据的分支判断:
b. 用户数据(积木块编号)处理逻辑:
参考颜色处理,添加一条新的逻辑线用于处理积木块编号:
其中
前置处理如下:
提示词设置如下:
后置处理如下:
具体代码如下://设置成功的语音提示 const setBlockSucc = "已完成功能块设置"; //设置失败的语音提示,留空表示使用大模型的回复 const setBlockFail = "很抱歉,没有找到连接的功能块"; //设置异常的语音提示 const setBlockErr = "很抱歉,我暂时无法完成该操作"; //数据模版,不建议直接修改 const intentTemplate = { "text": "", "rc": 0, "data": { "result": [{ "id": "xxx", "type": "block", "value": "1" }] }, "answer": { "text": "已完成设置", "type": "T" }, "service": "setBlock", "service_pkg": "media", "category": "LISTENAI.block" } //大模型回复内容 let content = msg.payload.choices[0]?.message?.content || ''; let nluProperty = msg.queryParams.param.nluProperty; let block_id = 0; if (nluProperty?.custom && nluProperty.custom?.block_id) { block_id = nluProperty.custom.block_id; } const block_names = [ "", "温湿度传感器功能块", "光线传感器功能块", "红外检测功能块", "烟雾报警器功能块", "火焰传感器功能块" ]; if (block_id){ printInfo("匹配到功能块编号:", block_id); intentTemplate.data.result[0].id = msg.payload.id; intentTemplate.data.result[0].value = block_id; intentTemplate.text = msg._asrResult; //构造tts合成文本 let ttsMsg = RED.util.cloneMessage(msg); ttsMsg.payload = { text: setBlockSucc + ",功能块编号为:" + block_id + ",其功能为" + block_names[block_id], stream: true, is_last: true }; //构造设置背景的数据帧给设备 let nluMsg = RED.util.cloneMessage(msg); nluMsg.payload = { type: "CUSTOM", intent: intentTemplate }; node.send([nluMsg, ttsMsg]); } else { printErr("匹配不到颜色:" , content); //构造tts合成文本 let ttsMsg = RED.util.cloneMessage(msg); ttsMsg.payload = { text: setBlockFail || content || setBlockErr, stream: true, is_last: true }; //若匹配不到颜色,只下发语音提示 node.send([null, ttsMsg]); } return; /** * 日志打印 */ function printInfo(tips, data) { let date = new Date(); let message = `[INFO] SID[${msg.queryParams?.sid || ''}] App[${msg.queryParams?.llmApp}] Device[${msg.queryParams.deviceId || ''}] Time[${`${date.getHours() + 8}:${date.getMinutes()}:${date.getSeconds()}.${date.getMilliseconds()}]`} ${tips}`; if (data) { if (typeof data === 'object') { console.log(message, JSON.stringify(data)) } else { console.log(message, data) } } else { console.log(message) } } /** * 日志打印 */ function printErr(tips, err) { let date = new Date(); let message = `[ERR ] SID[${msg.queryParams?.sid || ''}] App[${msg.queryParams?.llmApp}] Device[${msg.queryParams.deviceId || ''}] Time[${`${date.getHours() + 8}:${date.getMinutes()}:${date.getSeconds()}.${date.getMilliseconds()}]`} ${tips}`; console.error(message, err); }
在上述代码中,通过 msg.queryParams.param.nluProperty.custom.block_id来检查积木功能块的编号,其数据对应设备端 aiui_send_custom_data() 发送的数据。
功能开发完成后,编译烧录到开发板运行,进行测试。
功能测试
运行设备,并准备好积木功能块:
因为还在早期开发阶段,所以积木功能块直接使用Type-C上电,后续将会使用专用连接口,进行连接供电。
设备输出日志
- 积木功能块检测
将不同的积木功能模上电,输出日志如下:
从上述日志中,可以看到对应的积木功能块上电后,会自动检测到,并输出对应的编号。 - 按键发送编号测试
当积木功能块编号1上电后,按键K3,云端处理后,会收到如下信息:
从上述可以看到,云端返回了当前检测到的编号为1
当积木功能块编号2上电后,按键K3,云端处理后,会收到如下信息:
从上述可以看到,云端返回了当前检测到的编号为2
通过上面的测试,可以看到,现在能够检测到积木功能块了,并成功发送编号到云端进行处理。
在线编排调试输出
在云端交互的过程中,可以在在线编排界面,使用debug节点,及时输出调试信息:
下面,就测试录制视频,进行实际效果的展示。
演示视频
https://www.bilibili.com/vide...
总结
基于聆思大模型AI开发套件,以及自身提供的案例,以及官方人员的大力支持,这个智能电子积木平台的原型,得以快速的实现。因为是原型,所以目前功能还不是非常的完善。后续会进一步完善,接入实际使用的功能模块,提供不同的数据,达到可实际使用的阶段。
聆思提供了从端侧到云端的全套设备和服务,整体的配合使用非常的顺畅,极大地降低了大模型产品应用的开发,必须要点个赞!!!