18

HonestQiao · 3月24日 · 北京市

【聆思大模型AI开发套件】基于CSK6-MIX的智能积木平台

聆思大模型 AI 开发套件(CSK6-MIX)不仅提供了完善的多模态大模型调用功能,还提供了丰富的本地外设接口,可以通过端侧结合大模型来实现很多非常有趣的功能。

这篇分享,就是基于CSK6-MIX开发套件,结合外部设备,实现智能电子积木平台的功能。

在实现该智能电子积木平台的过程中,得到了聆思官方王志彬、赵卓斌、叶康、朱元恒等多位技术大佬手把手的指导,并不厌其烦的回答我的疑问,也得到了CSK开发小助手小慧姐姐的支持,在此表示特别特别的感谢!!!

功能概览

在这个智能电子积木平台上,通过串口,连接到积木块探测模块,获取当前连接的积木功能块编号,然后将该编号发送到云端,云端根据当前对应的功能设置,调用积木块编号对应的处理逻辑,处理完毕后,返回结果到智能电子积木平台,进行语音播放或者功能执行。

前期调研

  1. 参考案例
    聆思官方为CSK6-MIX提供了多种案例,经过实际使用和了解,使用多模态案例中的LLM_control座位基础,进行二次开发
  2. Zephyr设备树(devicetree)定义
    实现这个智能电子积木平台,首先需要了解设备端也就是开发板的开发。
    CSK6-MIX上的运行环境是基于Zephyr RTOS 作为操作系统,所以需要对Zephyr有一些了解,后面的开发中,会涉及到Zephyr的devicetree定义GPIO端口功能,其中包括串口和按键。
    通过SDK中csk的board定义中的csk6-pinctrl.h,可以得知GPIOA_08、GPIO_09可用于uart1:
    image.png

    从聆思官方提供的《多模态开发套件硬件原理图 V1.1》可以得知GPIOA_08、GPIO_09可以使用:
    image.png

    从原理图中,也可以得知CSK6直连的K3按键,连接到了GPIOB_00:
    image.png

  3. 外设驱动开发
    在这个智能电子积木平台的开发中,涉及到按键和UART的处理,因此需要了解官方文档外设驱动中的:
    a. GPIO
    b. UART
  4. 端云交互链路协议开发
    因为涉及到将语音以外的自定义用户数据(此处为积木功能块编号)发送到云端进行处理,所以需要了解官方文档中的 端云交互链路协议开发
  5. 在线编排
    聆思大模型平台LSPlatform提供了基于node-red的在线编排的功能,可以通过拖拽和预定于功能块的低代码方式,进行多模态大模型功能的调用,对于实际的开发工作非常的友好,大大提高了开发的效率。
    要了解在线编排,可以查看:https://docs2.listenai.com/x/ONjKxNdxZ

硬件准备

硬件设备

  1. 聆思大模型 AI 开发套件(CSK6-MIX)
    image.png
  2. ESP32-C3
    image.png
  3. 原始积木
    image.png

其中,ESP32-C3有3块,一块做为积木块探测模块使用,另外两块做为积木功能块使用。

硬件制作和连接

  1. 积木块探测模块连接:
    积木块需要使用串口,连接到CSK6-MIX开发板,并通过无线方式探测积木块。
    根据前面的了解,将积木块探测模块UART的TX连接到GPIOA_09,RX连接到GPIOA_08。
    image.png
  2. 积木块
    将ESP32-C3做好连线,安装到积木块的内容
    image.png

功能开发

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,发送自定义的用户数据到云端。
其具体的调用逻辑,在 端云交互链路协议开发 中有详细的说明:
image.png

设备树修改

在 apps/LLM_control/boards/csk6_duomotai_devkit.overlay 中,已经提供了核心的设备树定义,我们只需要在此基础上,添加UART和按键的定义即可,具体如下.

  1. 按键定义

    / {
         aliases {
                 sw0 = &user_button;
         };
         gpio_keys {
         compatible = "gpio-keys";
         user_button: button {
             label = "User";
             gpios = <&gpiob 00 GPIO_ACTIVE_LOW>;
         };
     };
    };
  2. 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);
                            }

该部分仅用于输出信息即可,目前没有做特殊的处理。

在线编排

关于设备接入和大模型基础开发的部分,这里就不专门说了,大家可以看官方文档:[如何搭建大模型智能硬件]image.png(https://docs2.listenai.com/x/...)

  1. 建立一个多模态应用:
    image.png
    image.png
  2. 在产品设置中关联
    image.png
  3. 设置提示词模版:
    通用:
    image.png
    image.png

    设置背景颜色:
    image.png

    编号处理:
    image.png

  4. 进入在线编排
    image.png

    a. 落域处理:
    在落域处理中,添加对用户数据的分支判断:
    image.png

    b. 用户数据(积木块编号)处理逻辑:
    参考颜色处理,添加一条新的逻辑线用于处理积木块编号:
    image.png

    其中
    前置处理如下:
    image.png

    提示词设置如下:
    image.png

    后置处理如下:
    image.png
    具体代码如下:

    //设置成功的语音提示
    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() 发送的数据。

功能开发完成后,编译烧录到开发板运行,进行测试。

功能测试

运行设备,并准备好积木功能块:
image.png

因为还在早期开发阶段,所以积木功能块直接使用Type-C上电,后续将会使用专用连接口,进行连接供电。

设备输出日志

  1. 积木功能块检测
    将不同的积木功能模上电,输出日志如下:
    image.png
    从上述日志中,可以看到对应的积木功能块上电后,会自动检测到,并输出对应的编号。
  2. 按键发送编号测试
    当积木功能块编号1上电后,按键K3,云端处理后,会收到如下信息:
    image.png
    从上述可以看到,云端返回了当前检测到的编号为1

当积木功能块编号2上电后,按键K3,云端处理后,会收到如下信息:
image.png
从上述可以看到,云端返回了当前检测到的编号为2

通过上面的测试,可以看到,现在能够检测到积木功能块了,并成功发送编号到云端进行处理。

在线编排调试输出

在云端交互的过程中,可以在在线编排界面,使用debug节点,及时输出调试信息:

下面,就测试录制视频,进行实际效果的展示。

演示视频

https://www.bilibili.com/vide...

总结

基于聆思大模型AI开发套件,以及自身提供的案例,以及官方人员的大力支持,这个智能电子积木平台的原型,得以快速的实现。因为是原型,所以目前功能还不是非常的完善。后续会进一步完善,接入实际使用的功能模块,提供不同的数据,达到可实际使用的阶段。

聆思提供了从端侧到云端的全套设备和服务,整体的配合使用非常的顺畅,极大地降低了大模型产品应用的开发,必须要点个赞!!!

推荐阅读
关注数
3
文章数
6
狂热的开源爱好者和传播者,为人精力充沛,古道热肠,圈内人称乔大妈、乔帮主。
目录
极术微信服务号
关注极术微信号
实时接收点赞提醒和评论通知
安谋科技学堂公众号
关注安谋科技学堂
实时获取安谋科技及 Arm 教学资源
安谋科技招聘公众号
关注安谋科技招聘
实时获取安谋科技中国职位信息