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

功能概览

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

前期调研

1.  参考案例

聆思官方为CSK6-MIX提供了多种案例,经过实际使用和了解,使用多模态案例中的LLM_control座位基础,进行二次开发

2.  Zephyr的devicetree定义

实现这个智能电子积木平台,首先需要了解设备端也就是开发板的开发。

CSK6-MIX上的运行环境是基于Zephyr RTOS 作为操作系统,所以需要对Zephyr有一些了解,后面的开发中,会涉及到GPIO端口功能,其中包括串口和按键。

通过SDK中csk的board定义中的csk6-pinctrl.h,可以得知GPIOA_08、GPIO_09可用于uart1:

【实战多模态大模型】基于CSK6-MIX开发板实现的智能积木平台_智能积木

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

【实战多模态大模型】基于CSK6-MIX开发板实现的智能积木平台_嵌入式_02

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

【实战多模态大模型】基于CSK6-MIX开发板实现的智能积木平台_大模型应用_03

3.  外设驱动开发

在这个智能电子积木平台的开发中,涉及到按键和UART的处理,因此需要了解官方文档外设驱动中的:

a.  GPIO | 聆思文档中心

b. UART | 聆思文档中心

4.  端云交互链路协议开发

因为涉及到将语音以外的自定义用户数据(此处为积木功能块编号)发送到云端进行处理,所以需要了解官方文档中的 端云交互链路协议 | 聆思文档中心

5.  在线编排

聆思大模型平台LSPlatform提供了基于node-red的在线编排的功能,可以通过拖拽和预定于功能块的低代码方式,进行多模态大模型功能的调用,对于实际的开发工作非常的友好,大大提高了开发的效率。

要了解在线编排,可以查看:1分钟构建大模型应用 | 聆思文档中心

硬件准备

硬件设备

1.  聆思大模型 AI 开发套件(CSK6-MIX)

【实战多模态大模型】基于CSK6-MIX开发板实现的智能积木平台_zephyr_04

2.  ESP32-C3

【实战多模态大模型】基于CSK6-MIX开发板实现的智能积木平台_RTOS_05

3.  原始积木

【实战多模态大模型】基于CSK6-MIX开发板实现的智能积木平台_嵌入式_06

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

硬件制作和连接

1.  积木块探测模块连接:

积木块需要使用串口,连接到CSK6-MIX开发板,并通过无线方式探测积木块。

根据前面的了解,将积木块探测模块UART的TX连接到GPIOA_09,RX连接到GPIOA_08。

【实战多模态大模型】基于CSK6-MIX开发板实现的智能积木平台_大模型应用_07

2.  积木块

将ESP32-C3做好连线,安装到积木块的内容

【实战多模态大模型】基于CSK6-MIX开发板实现的智能积木平台_大模型应用_08

功能开发

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,发送自定义的用户数据到云端。

其具体的调用逻辑,在 端云交互链路协议开发 中有详细的说明:

【实战多模态大模型】基于CSK6-MIX开发板实现的智能积木平台_嵌入式_09



设备树修改

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

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

在线编排

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

【实战多模态大模型】基于CSK6-MIX开发板实现的智能积木平台_zephyr_10

1.  建立一个多模态应用:

【实战多模态大模型】基于CSK6-MIX开发板实现的智能积木平台_大模型应用_11


【实战多模态大模型】基于CSK6-MIX开发板实现的智能积木平台_嵌入式_12

2.  在产品设置中关联

【实战多模态大模型】基于CSK6-MIX开发板实现的智能积木平台_大模型应用_13

3.  设置提示词模版:

通用:

【实战多模态大模型】基于CSK6-MIX开发板实现的智能积木平台_大模型应用_14


【实战多模态大模型】基于CSK6-MIX开发板实现的智能积木平台_大模型应用_15

设置背景颜色:

【实战多模态大模型】基于CSK6-MIX开发板实现的智能积木平台_大模型应用_16

编号处理:

【实战多模态大模型】基于CSK6-MIX开发板实现的智能积木平台_RTOS_17

4.  进入在线编排

【实战多模态大模型】基于CSK6-MIX开发板实现的智能积木平台_智能积木_18

a. 落域处理:

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

【实战多模态大模型】基于CSK6-MIX开发板实现的智能积木平台_智能积木_19

b. 用户数据(积木块编号)处理逻辑:

参考颜色处理,添加一条新的逻辑线用于处理积木块编号:

【实战多模态大模型】基于CSK6-MIX开发板实现的智能积木平台_RTOS_20

其中

前置处理如下:

【实战多模态大模型】基于CSK6-MIX开发板实现的智能积木平台_zephyr_21

提示词设置如下:

【实战多模态大模型】基于CSK6-MIX开发板实现的智能积木平台_zephyr_22

后置处理如下:

【实战多模态大模型】基于CSK6-MIX开发板实现的智能积木平台_嵌入式_23

具体代码如下:

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

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

功能测试

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

【实战多模态大模型】基于CSK6-MIX开发板实现的智能积木平台_大模型应用_24

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

设备输出日志

1.  积木功能块检测

将不同的积木功能模上电,输出日志如下:

【实战多模态大模型】基于CSK6-MIX开发板实现的智能积木平台_智能积木_25

2.  从上述日志中,可以看到对应的积木功能块上电后,会自动检测到,并输出对应的编号。按键发送编号测试

当积木功能块编号1上电后,按键K3,云端处理后,会收到如下信息:

【实战多模态大模型】基于CSK6-MIX开发板实现的智能积木平台_智能积木_26

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

【实战多模态大模型】基于CSK6-MIX开发板实现的智能积木平台_嵌入式_27

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

在线编排调试输出

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

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

演示视频

https://www.bilibili.com/video/BV19j421o7qC/?t=2.561225&spm_id_from=333.1350.jump_directly

总结

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

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

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

示例分享来自社区博主HonestQiao ,原文链接:【聆思大模型AI开发套件】基于CSK6-MIX的智能积木平台 

示例使用的开发板SDK及资料文档:套件简介 | 聆思文档中心