当前位置:   article > 正文

【聆思CSK6视觉AI开发套件试用】SDK实用性评测

聆思csk6

本篇文章来自极术社区与聆思科技组织的CSK6 视觉AI开发套件活动,更多开发板试用活动请关注极术社区网站。作者:李奎

因为后续想基于CSK6做产品,所以比较关心以下几点:

  • **【硬件支持】**CSK6提供的硬件能力和AI能力是否满足需求;
  • **【软件支持】**开发SDK是否稳定,方便开发人员更高效开发;
  • **【文档支持】**开放文档是否完善,后续是否有及时的技术支持;

总结:

整体玩下来,文档还是非常丰富的,从硬件参考设计、软件SDK和案例、涉及的知识能力等,看的出来花了很多的心思,好评 。

总体评估整体实际实用风险不大,部分SDK和AI开放能力有发展空间

以下结合对SDK的评测,说明其中遇到的一些问题、疑问,以及对后续的期待

1、AI能力评测

通过官方提供的指导文档,可以比较容易的完成AI能力的Demo验证,比如人脸识别提供SDK源码下载地址和Sample的下载运行例程和说明,小白也可以轻松上手。

根据文档编译烧录后,通过官方提供的基于USB Web浏览器,接上板子的USB口,可以实时预览人脸识别的Demo效果。(以下是使用手机,出境人员是如花),效果上由于画面压缩,显示的图像质量不高,实际算法中是480*640的分辨率。

考虑到实用性,对人脸识别进行了一些非人脸的验证,从目前的情况看,会有一定识别错误的风险。

但在正常人脸识别时得分比较高,在做判断时,需要考虑更高的得分用于判定人脸相似度,比如得分在0.8以上判定为人脸。

另外期待AI能力后续能开放出来,并支持一些通用的机器学习、深度学习框架和模型。

对比K210、V831、V833、V853这些,在Tensorflow、Pytorch、YoLo等的一些支持,也增加了很多的玩法和场景应用的可能。

另外AIoT可以结合网络,增加用户的粘性,比如MaixHub在线训练平台,体验了下,设备的在线联动比较顺滑,体验效果不错。

2、Wi-Fi能力评测

以上提到了AIoT的能力。正好CSK6是作为AIoT的方案,套件中也提供了ESP32-C3的模块。接下来测试验证下网络的功能是否满足一些实际实用场景的需求。以下分别对官方Demo、SDK二次开发,后续再测试下TCP网络测速、实际实用场景测评。

2.1 Demo验证

在官方文档的开发实践->网络章节可以找到WIFI连接和网络通信的案例,根据说明,很容易验证Wi-Fi和网络通信的能力,这里就不过多介绍,可以参考官方的文档,赞一个 。

2.2 SDK二次开发

由于网络遵循了Posix Socket API接口规范,因此做过Linux应用开发的同学应该就非常爽了,这也是一个亮点。

考虑到应用层开发人员不需要关系Kconfig和DTS机制,因此针对提供的接口又做了一次封装,比如Wi-Fi模块,提供了统一向上的接口暴露,方便招的应用层业务开发人员使用:

#include <wifi_core.h>
#include <log.h>

void wifi_test(void)
{
    // 初始化Wi-Fi模块
    wifi.init();

    // 连接AP热点
    wifi.connect_ap("mimi", "xxxxxxxx");
}
  • 1
  • 2
  • 3
  • 4
  • 5
  • 6
  • 7
  • 8
  • 9
  • 10
  • 11

以上wifi\_core.c/h对提供的API做了封装,大概如下。注意这里的连接AP热点是异步的,接口提供了状态用于判断。

实际实用的情况下,最好使用信号量等机制控制。

wifi\_core.h接口文件:

#ifndef _WIFI_BLE_H
#define _WIFI_BLE_H

#include <stdbool.h>

/// \brief Wi-Fi模块的返回状态
typedef enum {
    WIFI_OK,
    WIFI_ERROR
}wifi_status_t;

/// \brief Wi-Fi模块的当前模式
typedef enum {
    WIFI_NONE_MODE      = 0,
    WIFI_STA_MODE       = 1,
    WIFI_AP_MODE        = 2,
    WIFI_AP_STA_MODE    = 3,
}wifi_mode_t;

/**
 * @brief
 *  封装了STA相关的接口,后续增加AP和其他相关接口功能
 */
typedef struct {
    char mac[24];           /*!< MAC地址 */
    char ip[16];            /*!< 设备IP */
    char netmask[16];       /*!< 子网掩码 */
    char gw[16];            /*!< 网关 */
    char ssid[32];          /*!< 连接热点名称 */
    char passwd[32];        /*!< 连接热点密码 */

    /**
    * @brief 初始化wifi模块
    *
    * @return wifi_status_t 返回状态
    */
    wifi_status_t (*init)(void);

    /**
    * @brief 反初始化wifi模块
    *
    * @return wifi_status_t 返回状态
    */
    wifi_status_t (*uninit)(void);

    /**
    * @brief 连接热点
    *
    * @return wifi_status_t 返回状态
    */
    wifi_status_t (*connect_ap)(const char* ssid, const char* passwd);

    /**
     * @brief: 断开热点
     * @return wifi_status_t 返回状态
     */    
    wifi_status_t (*disconnect_ap)(void);

    // 
    /**
     * @brief: 扫描可用热点
     * @return wifi_status_t 返回状态
     */    
    // wifi_status_t (*scan_ap)(void);

    /**
     * @brief: 检查Wi-Fi模块是否工作正常
     * @return wifi_status_t 返回状态
     */
    bool (*is_connect)(void);
    /*!< Wi-Fi当前连接状态 */
    bool connect_status;
    
}wifi_t;

///< wifi单例
///< ESP32-C3产品说明:https://www.espressif.com.cn/zh-hans/products/socs/esp32-c3
extern wifi_t wifi;

#endif
  • 1
  • 2
  • 3
  • 4
  • 5
  • 6
  • 7
  • 8
  • 9
  • 10
  • 11
  • 12
  • 13
  • 14
  • 15
  • 16
  • 17
  • 18
  • 19
  • 20
  • 21
  • 22
  • 23
  • 24
  • 25
  • 26
  • 27
  • 28
  • 29
  • 30
  • 31
  • 32
  • 33
  • 34
  • 35
  • 36
  • 37
  • 38
  • 39
  • 40
  • 41
  • 42
  • 43
  • 44
  • 45
  • 46
  • 47
  • 48
  • 49
  • 50
  • 51
  • 52
  • 53
  • 54
  • 55
  • 56
  • 57
  • 58
  • 59
  • 60
  • 61
  • 62
  • 63
  • 64
  • 65
  • 66
  • 67
  • 68
  • 69
  • 70
  • 71
  • 72
  • 73
  • 74
  • 75
  • 76
  • 77
  • 78
  • 79
  • 80

wifi\_core.c文件内容如下:

#include <stdio.h>
#include <string.h>
#include <stdlib.h>
#include <sys/printk.h>
#include <sys/sys_heap.h>

#include <net/net_if.h>
#include <net/net_core.h>
#include <net/net_context.h>
#include <net/net_mgmt.h>

#include "csk6/csk_wifi.h"

#include <log.h>
#include <wifi_core.h>

__weak wifi_status_t esp32_c3_wifi_init(void);
__weak wifi_status_t esp32_c3_wifi_uninit(void);
__weak wifi_status_t esp32_c3_wifi_connect_ap(const char* ssid, 
                                            const char* passwd);
__weak wifi_status_t esp32_c3_wifi_disconnect_ap(void);
// __weak wifi_status_t esp32_c3_wifi_scan_ap(void);
__weak bool esp32_c3_wifi_is_connect(void);

wifi_t wifi = {
    .init           = esp32_c3_wifi_init,
    .uninit         = esp32_c3_wifi_uninit,
    .connect_ap     = esp32_c3_wifi_connect_ap,
    .disconnect_ap  = esp32_c3_wifi_disconnect_ap,
    // .scan_ap        = esp32_c3_wifi_scan_ap,
    .is_connect     = esp32_c3_wifi_is_connect,
    .connect_status = false,
};


static csk_wifi_event_cb_t wifi_event_cb;
static csk_wifi_result_t wifi_result;
static struct net_mgmt_event_callback dhcp_cb;
static void handler_cb(struct net_mgmt_event_callback *cb, 
                    uint32_t mgmt_event, struct net_if *iface)
{
    if (mgmt_event != NET_EVENT_IPV4_DHCP_BOUND) {
        return;
    }

    char buf[NET_IPV4_ADDR_LEN];

    snprintf(wifi.ip, sizeof(wifi.ip), "%s", 
        net_addr_ntop(AF_INET, &iface->config.dhcpv4.requested_ip, 
                    buf, sizeof(buf)));
    snprintf(wifi.netmask, sizeof(wifi.netmask), "%s", 
        net_addr_ntop(AF_INET, &iface->config.ip.ipv4->netmask, 
                    buf, sizeof(buf)));
    snprintf(wifi.gw, sizeof(wifi.gw), "%s", 
        net_addr_ntop(AF_INET, &iface->config.ip.ipv4->gw, 
                    buf, sizeof(buf)));

    LOG(EDEBUG, "Your address: %s,Subnet: %s,Router: %s", 
        wifi.ip, wifi.netmask, wifi.gw);
    
    wifi.connect_status = true;
}


static void wifi_event_handler(csk_wifi_event_t events, void *event_data,
                                uint32_t data_len, void *arg)
{
    if (events & CSK_WIFI_EVT_STA_CONNECTED) {
        // wifi.connect_status = true;
        LOG(EDEBUG, "[WiFi sta] connected");
    } else if (events & CSK_WIFI_EVT_STA_DISCONNECTED) {
        wifi.connect_status = false;
        LOG(EDEBUG, "[WiFi sta] disconnected");
    } else {
        abort();
    }
}


__weak wifi_status_t esp32_c3_wifi_init(void)
{
    uint8_t mac_addr[6] = {0};

    /* CSK WiFi 驱动初始化 */
    int rc = csk_wifi_init();
    if (rc != 0) {
        LOG(EERROR, "wifi get mac failed, ret: %d\n", rc);
        return WIFI_ERROR;
    }
    rc = csk_wifi_get_mac(CSK_WIFI_MODE_STA, mac_addr);
    if (rc != 0) {
        LOG(EERROR, "wifi get mac failed, ret: %d\n", rc);
        return WIFI_ERROR;
    }
    snprintf(wifi.mac, sizeof(wifi.mac), "%x:%x:%x:%x:%x:%x", 
            mac_addr[0], mac_addr[1], mac_addr[2], mac_addr[3],
            mac_addr[4], mac_addr[5]);
    LOG(EDEBUG, "mac address:%s", wifi.mac);

    return (rc == 0)?WIFI_OK:WIFI_ERROR;
}


__weak wifi_status_t esp32_c3_wifi_uninit(void)
{
    /* CSK WiFi 驱动初始化 */
    int rc = csk_wifi_deinit();

    return (rc == 0)?WIFI_OK:WIFI_ERROR;
}


__weak wifi_status_t esp32_c3_wifi_connect_ap(const char* ssid, 
                                                const char* passwd)
{
    /* 配置WiFi回调事件参数 */
    wifi_event_cb.handler = &wifi_event_handler;
    wifi_event_cb.events = 
        CSK_WIFI_EVT_STA_CONNECTED | CSK_WIFI_EVT_STA_DISCONNECTED;
    wifi_event_cb.arg = NULL;

    /* 注册WiFi回调事件 */
    csk_wifi_add_callback(&wifi_event_cb);

    /* WiFi参数配置 */
    csk_wifi_sta_config_t sta_config = {0};
    snprintf(sta_config.ssid, sizeof(sta_config.ssid), "%s", ssid);
    snprintf(sta_config.pwd, sizeof(sta_config.pwd), "%s", passwd);
    sta_config.encryption_mode = CSK_WIFI_AUTH_WPA2_PSK;

    int retry_count = 0;
    do {
        LOG(EDEBUG, "connecting to wifi: %s ...", sta_config.ssid);
        /* 连接WiFi */
        int ret = csk_wifi_sta_connect(&sta_config, &wifi_result, K_FOREVER);
        if (ret == 0) {
            snprintf(wifi.ssid, sizeof(wifi.ssid), "%s", ssid);
            snprintf(wifi.passwd, sizeof(wifi.passwd), "%s", passwd);
            break;
        } else {
            if (wifi_result == CSK_WIFI_ERR_STA_FAILED) {
                retry_count++;
                LOG(EERROR, "retry to connecting wifi ... %d", retry_count);
            } else {
                LOG(EERROR, "AP not found or invalid password");
                return WIFI_ERROR;
            }
        }
    } while (retry_count < 10);

    if (retry_count >= 10) return WIFI_ERROR;
    
    /* 打印已连接WiFi信息 */
    LOG(EDEBUG, "--------------------------Current AP info-------------------------------");
    LOG(EDEBUG, "ssid: %s  pwd: %s  bssid: %s  channel: %d  rssi: %d\n",
        sta_config.ssid, sta_config.pwd, sta_config.bssid, sta_config.channel,
        sta_config.rssi);
    LOG(EDEBUG, "------------------------------------------------------------------------");
    /* 初始化并注册 DHCP BOUND 事件,设备获取 ipv4 地址后产生回调 */
    net_mgmt_init_event_callback(&dhcp_cb, handler_cb, NET_EVENT_IPV4_DHCP_BOUND);
    net_mgmt_add_event_callback(&dhcp_cb);
    struct net_if *iface = net_if_get_default();
    if (!iface) {
        LOG(EDEBUG, "wifi interface not available");
        return WIFI_ERROR;
    }
    /* 开启dhcp client,DHCP 用来分配 IP */
    net_dhcpv4_start(iface);

    return WIFI_OK;
}


__weak wifi_status_t esp32_c3_wifi_disconnect_ap(void)
{
    int rc = csk_wifi_sta_disconnect(&wifi_result, K_FOREVER);

    return (rc == 0)?WIFI_OK:WIFI_ERROR;
}


__weak bool esp32_c3_wifi_is_connect(void)
{
    return wifi.connect_status;
}
  • 1
  • 2
  • 3
  • 4
  • 5
  • 6
  • 7
  • 8
  • 9
  • 10
  • 11
  • 12
  • 13
  • 14
  • 15
  • 16
  • 17
  • 18
  • 19
  • 20
  • 21
  • 22
  • 23
  • 24
  • 25
  • 26
  • 27
  • 28
  • 29
  • 30
  • 31
  • 32
  • 33
  • 34
  • 35
  • 36
  • 37
  • 38
  • 39
  • 40
  • 41
  • 42
  • 43
  • 44
  • 45
  • 46
  • 47
  • 48
  • 49
  • 50
  • 51
  • 52
  • 53
  • 54
  • 55
  • 56
  • 57
  • 58
  • 59
  • 60
  • 61
  • 62
  • 63
  • 64
  • 65
  • 66
  • 67
  • 68
  • 69
  • 70
  • 71
  • 72
  • 73
  • 74
  • 75
  • 76
  • 77
  • 78
  • 79
  • 80
  • 81
  • 82
  • 83
  • 84
  • 85
  • 86
  • 87
  • 88
  • 89
  • 90
  • 91
  • 92
  • 93
  • 94
  • 95
  • 96
  • 97
  • 98
  • 99
  • 100
  • 101
  • 102
  • 103
  • 104
  • 105
  • 106
  • 107
  • 108
  • 109
  • 110
  • 111
  • 112
  • 113
  • 114
  • 115
  • 116
  • 117
  • 118
  • 119
  • 120
  • 121
  • 122
  • 123
  • 124
  • 125
  • 126
  • 127
  • 128
  • 129
  • 130
  • 131
  • 132
  • 133
  • 134
  • 135
  • 136
  • 137
  • 138
  • 139
  • 140
  • 141
  • 142
  • 143
  • 144
  • 145
  • 146
  • 147
  • 148
  • 149
  • 150
  • 151
  • 152
  • 153
  • 154
  • 155
  • 156
  • 157
  • 158
  • 159
  • 160
  • 161
  • 162
  • 163
  • 164
  • 165
  • 166
  • 167
  • 168
  • 169
  • 170
  • 171
  • 172
  • 173
  • 174
  • 175
  • 176
  • 177
  • 178
  • 179
  • 180
  • 181
  • 182
  • 183
  • 184
  • 185

以上程序烧录后,串口打印的结果如下:

[wifi_core.c esp32_c3_wifi_init:94][EDEBUG]mac address:10:91:a8:3e:9b:54
[wifi_core.c esp32_c3_wifi_connect_ap:129][EDEBUG]connecting to wifi: mimi ...
[wifi_core.c esp32_c3_wifi_connect_ap:150][EDEBUG]--------------------------Current AP info-------------------------------
[wifi_core.c esp32_c3_wifi_connect_ap:151][EDEBUG]ssid: mimi  pwd: xxxxxxxx  bssid: 52:fc:57:12:03:ec  channel: 1  rssi: -48
[wifi_core.c esp32_c3_wifi_connect_ap:154][EDEBUG]------------------------------------------------------------------------
[wifi_core.c handler_cb:56][EDEBUG]Your address: 192.168.43.96,Subnet: 255.255.255.0,Router: 192.168.43.1
  • 1
  • 2
  • 3
  • 4
  • 5
  • 6

2.3 实用场景——AP配网

验证了Wi-Fi能力后,接下来考虑的就是用户在使用时的配网怎样更加方便。

印象中,买过的一些不带屏的硬件产品,其中之一的配网流程如下:

  • 长按设备端按键3秒;
  • 指示灯快速闪烁,表示处于配网模式;
  • 手机App指导设备联网;
  • 设备由快闪变为慢速呼吸效果时,表示联网成功;

当然除了以上的方式还有ESP32-C3支持的AirKiss、EspTouch、微信小程序、声波配网等。打算使用提供的wifi\_ap的案例,完成AP配网模式验证下效果。

这个案例在这里,通过以下命令创建,编译烧录即可,注意编译时选择csk6011a\\_c3\\_nano作为boards编译。

程序运行后,通过手机搜索ESP的设备SSID,点击连接。但是这里的Demo提供的是不带DHCP服务的,不能自动给设备自动分配IP,需要手机手动设置IP后才能正常连接到设备上。记得ESP32-C3是有的,不知道SDK是否提供了DHCP服务的能力

以上的AP配网由于Demo没有提供自动给客户端分配IP的功能,没再继续找相关接口,想了下还有BLE的能力,但是需要手机App,也会比较麻烦。算了,这块还是问下聆思的大胸弟们吧

虽然暂时AP和BLE都不使用了,但是作为开发人员,需要用到网络功能的时候,总不能把Wi-Fi的SSID和密码都写死在设备上吧,下次丢给其他人调试网络功能,也会很不方便,还要改密码,因此想到了SDK提供的串口命令功能,咱们可以设计一个wifi connect命令,来动态配网,接下来就试一下。

3、串口命令一键配网实现

看到了在开发文档开发实践->Shell的使用提供了一个Wi-Fi的案例,但未具体实现Wi-Fi配网的功能,接下来就实现一下。最终的效果是设备启动后,命令行输入tab可以看到wifi的指令,通过在命令行输出wifi connect ssid passwd 1,即可一键动态连接到AP网络,省的还要改代码进行联网了

uart:~$
  clear    device   devmem   flash    help     history  kernel   pwm
  resize   shell    wifi
uart:~$ wifi connect mimi 12348765 1
[shell_command.c cmd_wifi_connect:75][EDEBUG]cmd_wifi_connect ssid: mimi psw: 12348765 save: 1
[wifi_core.c esp32_c3_wifi_init:94][EDEBUG]mac address:10:91:a8:3e:9b:54
[wifi_core.c esp32_c3_wifi_connect_ap:129][EDEBUG]connecting to wifi: mimi ...
[wifi_core.c esp32_c3_wifi_connect_ap:150][EDEBUG]--------------------------Current AP info-------------------------------
[wifi_core.c esp32_c3_wifi_connect_ap:151][EDEBUG]ssid: mimi  pwd: xxxxxxxx  bssid: 52:fc:57:12:03:ec  channel: 1  rssi: -48
[wifi_core.c esp32_c3_wifi_connect_ap:154][EDEBUG]------------------------------------------------------------------------
[wifi_core.c handler_cb:56][EDEBUG]Your address: 192.168.43.96,Subnet: 255.255.255.0,Router: 192.168.43.1
  • 1
  • 2
  • 3
  • 4
  • 5
  • 6
  • 7
  • 8
  • 9
  • 10
  • 11

以上的功能通过shell\\_command.c中完成,shell\\_command.c如下:

/* connect 指令处理函数,附带wifi连接的参数 */
static int cmd_wifi_connect(const struct shell *shell, 
                            size_t argc, char **argv)
{
    int rc = 0;

    LOG(EDEBUG, "cmd_wifi_connect ssid: %s psw: %s save: %d \n", 
        argv[1], argv[2], atoi(argv[3]));

    // 判断是否已经初始化Wi-Fi模块
    if (!wifi.connect_status) {
        if (WIFI_OK != wifi.init()) {
            LOG(EERROR, "wifi init failed");
            return -1;
        }
        // 连接AP热点
        if (WIFI_OK != wifi.connect_ap(argv[1], argv[2])) {
            LOG(EERROR, "wifi connect ap ssid:%s failed", argv[1]);
            return -1;
        }
    }

    return rc;
}

/// @brief 添加wifi命令的子命令(connect)
SHELL_STATIC_SUBCMD_SET_CREATE(init_deinit_command,
SHELL_CMD(connect, NULL, 
    "<ssid> <pwd> <save>, example:mimi 12345678 1", 
    cmd_wifi_connect),
SHELL_SUBCMD_SET_END /* Array terminated. */
);

/// @brief 添加WiFi命令集的根命令
SHELL_CMD_REGISTER(wifi, &init_deinit_command, "wifi command", NULL);
  • 1
  • 2
  • 3
  • 4
  • 5
  • 6
  • 7
  • 8
  • 9
  • 10
  • 11
  • 12
  • 13
  • 14
  • 15
  • 16
  • 17
  • 18
  • 19
  • 20
  • 21
  • 22
  • 23
  • 24
  • 25
  • 26
  • 27
  • 28
  • 29
  • 30
  • 31
  • 32
  • 33
  • 34
  • 35

通过以上的命令满足了对于开发人员的配网友好度。但还有个问题,设备掉电不会重连,下次还要重新输入进行配网。因此准备再次验证板载的16M SPI Flash和littlefs文件系统,当输入wifi账号密码连接成功后,把连接的信息保存到Flash中,下次上电执行自动重连机制。

4、文件系统SDK测评——Wi-Fi自动联网

这个部分是借用littlefs,保存Wi-Fi账号密码到Flash,系统上电后,会读取Flash中的Wi-Fi账号密码,自动联网。

这块的文档在开发实践->文件系统的使用章节,注意这里需要使用mklittlefs工具制作一个文件系统镜像,然后再通过命令烧入到板子的相关分区,最后还要根据csk6011a\_nano.overlay提供的文档,配置设备树的挂载点和系统固定分区,写入你烧录的地址和空间即可。

完成后,需要写一个测试Demo验证下挂载、遍历文件系统目录、创建删除文件、读写文件的案例来验证功能的完整性,完成这些功能验证的代码为flash\_test.c,内容如下:

#include <log.h>
#include <zephyr/fs/fs.h>
#include <flash_core.h>

#define MAX_PATH_LEN            255
#define FLASH_MAX_DATA_LEN      256
#define FLASH_TEST_DATA         "this is a flash read/write test data"
#define FLASH_TEST_FILE_NAME    "/lfs/test.txt"

void flash_test()
{
    int rc;
    struct fs_dirent dirent;
    char* fname = FLASH_TEST_FILE_NAME;
    char test_data[FLASH_MAX_DATA_LEN] = {0};
    struct fs_file_t file = {0};    /*!< 初始化结构体 */

    // 初始化挂载flash设备到/lfs节点
    user_flash_init();

    /* 如果文件先删除 */
    fs_unlink(fname);

    /* 打开文件 */
    rc = fs_open(&file, fname, FS_O_CREATE | FS_O_RDWR);
    if (rc < 0) {
        LOG(EERROR, "FAIL: open %s: %d", fname, rc);
        return ;
    }

    /* 获取文件状态信息 */
    rc = fs_stat(fname, &dirent);
    if (rc < 0) {
        LOG(EERROR, "FAIL: stat %s: %d", fname, rc);
        goto out;
    }

    /* 写文件内容 */
    rc = fs_write(&file, FLASH_TEST_DATA, strlen(FLASH_TEST_DATA));
    if (rc < 0) {
        LOG(EERROR, "FAIL: write %s: %d", fname, rc);
    }

    /* 重定向文件读写指针到开始 */
    rc = fs_seek(&file, 0, FS_SEEK_SET);
    if (rc < 0) {
        LOG(EERROR, "FAIL: seek %s: %d", fname, rc);
        goto out;
    }

    /* 读文件内容 */
    rc = fs_read(&file, test_data, sizeof(test_data));
    if (rc < 0) {
        LOG(EERROR, "FAIL: read %s: [rd:%d]", fname, rc);
        goto out;
    }
    LOG(EDEBUG, "read from %s, data:%s", fname, test_data);

out:
    fs_close(&file);
}
  • 1
  • 2
  • 3
  • 4
  • 5
  • 6
  • 7
  • 8
  • 9
  • 10
  • 11
  • 12
  • 13
  • 14
  • 15
  • 16
  • 17
  • 18
  • 19
  • 20
  • 21
  • 22
  • 23
  • 24
  • 25
  • 26
  • 27
  • 28
  • 29
  • 30
  • 31
  • 32
  • 33
  • 34
  • 35
  • 36
  • 37
  • 38
  • 39
  • 40
  • 41
  • 42
  • 43
  • 44
  • 45
  • 46
  • 47
  • 48
  • 49
  • 50
  • 51
  • 52
  • 53
  • 54
  • 55
  • 56
  • 57
  • 58
  • 59
  • 60
  • 61

flash\\_test.c文件调用的封装层flash\\_core.c/h内容如下。

flash\_core.h文件内容:

#ifndef _FLASH_CORE_H
#define _FLASH_CORE_H

/**
 * @brief flash文件系统初始化,根目录为/lfs1,初始化之后,可以使用zephyr的文件系统API接口访问文件
 * @return 0成功,其他值失败
 */
int user_flash_init(void);

/**
 * @brief 注销flash文件系统
 * @return 0成功,其他值失败
 */
int user_flash_uninit(void);

#endif
  • 1
  • 2
  • 3
  • 4
  • 5
  • 6
  • 7
  • 8
  • 9
  • 10
  • 11
  • 12
  • 13
  • 14
  • 15
  • 16

flash\_core.c文件内容如下:

#include <zephyr/zephyr.h>
#include <zephyr/device.h>
#include <zephyr/fs/fs.h>
#include <zephyr/fs/littlefs.h>
#include <zephyr/storage/flash_map.h>
#include <delay.h>
#include <log.h>

#include <flash_core.h>


/// 文件系统设备树信息
#ifdef CONFIG_APP_LITTLEFS_STORAGE_FLASH
static int littlefs_flash_erase(unsigned int id)
{
    const struct flash_area *pfa;
    int rc;

    rc = flash_area_open(id, &pfa);
    if (rc < 0) {
        LOG(EERROR, "FAIL: unable to find flash area %u: %d\n",
            id, rc);
        return rc;
    }

    LOG(EDEBUG, "Area %u at 0x%x on %s for %u bytes\n",
           id, (unsigned int)pfa->fa_off, pfa->fa_dev_name,
           (unsigned int)pfa->fa_size);

    /* Optional wipe flash contents */
    if (IS_ENABLED(CONFIG_APP_WIPE_STORAGE)) {
        rc = flash_area_erase(pfa, 0, pfa->fa_size);
        LOG(EERROR, "Erasing flash area ... %d", rc);
    }

    flash_area_close(pfa);
    return rc;
}
#define PARTITION_NODE DT_NODELABEL(lfs1)

#if DT_NODE_EXISTS(PARTITION_NODE)
FS_FSTAB_DECLARE_ENTRY(PARTITION_NODE);
#else /* PARTITION_NODE */
FS_LITTLEFS_DECLARE_DEFAULT_CONFIG(storage);
static struct fs_mount_t lfs_storage_mnt = {
    .type = FS_LITTLEFS,
    .fs_data = &storage,
    .storage_dev = (void *)FLASH_AREA_ID(storage),
    .mnt_point = "/lfs",
};
#endif /* PARTITION_NODE */

    struct fs_mount_t *mp =
#if DT_NODE_EXISTS(PARTITION_NODE)
        &FS_FSTAB_ENTRY(PARTITION_NODE)
#else
        &lfs_storage_mnt
#endif
        ;

static int littlefs_mount(struct fs_mount_t *mp)
{
    int rc;

    rc = littlefs_flash_erase((uintptr_t)mp->storage_dev);
    if (rc < 0) {
        return rc;
    }

    /* Do not mount if auto-mount has been enabled */
#if !DT_NODE_EXISTS(PARTITION_NODE) ||                      \
    !(FSTAB_ENTRY_DT_MOUNT_FLAGS(PARTITION_NODE) & FS_MOUNT_FLAG_AUTOMOUNT)
    rc = fs_mount(mp);
    if (rc < 0) {
        LOG(EDEBUG, "FAIL: mount id %" PRIuPTR " at %s: %d\n",
               (uintptr_t)mp->storage_dev, mp->mnt_point, rc);
        return rc;
    }
    LOG(EDEBUG, "%s mount: %d\n", mp->mnt_point, rc);
#else
    LOG(EDEBUG, "%s automounted\n", mp->mnt_point);
#endif

    return 0;
}
#endif /* CONFIG_APP_LITTLEFS_STORAGE_FLASH */

#ifdef CONFIG_APP_LITTLEFS_STORAGE_BLK_SDMMC
struct fs_littlefs lfsfs;
static struct fs_mount_t __mp = {
    .type = FS_LITTLEFS,
    .fs_data = &lfsfs,
    .flags = FS_MOUNT_FLAG_USE_DISK_ACCESS,
};
struct fs_mount_t *mp = &__mp;

static int littlefs_mount(struct fs_mount_t *mp)
{
    static const char *disk_mount_pt = "/"CONFIG_SDMMC_VOLUME_NAME":";
    static const char *disk_pdrv = CONFIG_SDMMC_VOLUME_NAME;

    mp->storage_dev = (void *)disk_pdrv;
    mp->mnt_point = disk_mount_pt;

    return fs_mount(mp);
}
#endif /* CONFIG_APP_LITTLEFS_STORAGE_BLK_SDMMC */


static int lsdir(const char *path)
{
    int res;
    struct fs_dir_t dirp;
    static struct fs_dirent entry;

    fs_dir_t_init(&dirp);

    /* Verify fs_opendir() */
    res = fs_opendir(&dirp, path);
    if (res) {
        LOG(EERROR, "Error opening dir %s [%d]", path, res);
        return res;
    }

    LOG(EDEBUG, "Listing dir %s ...", path);
    for (;;) {
        /* Verify fs_readdir() */
        res = fs_readdir(&dirp, &entry);

        /* entry.name[0] == 0 means end-of-dir */
        if (res || entry.name[0] == 0) {
            if (res < 0) {
                LOG(EERROR, "Error reading dir [%d]", res);
            }
            break;
        }

        if (entry.type == FS_DIR_ENTRY_DIR) {
            LOG(EDEBUG, "[DIR ] %s", entry.name);
        } else {
            LOG(EDEBUG, "[FILE] %s (size = %zu)",
                   entry.name, entry.size);
        }
    }

    /* Verify fs_closedir() */
    fs_closedir(&dirp);

    return res;
}


int user_flash_init()
{
    static bool is_init = false;
    struct fs_statvfs sbuf;
    int rc;

    if (is_init) return 0;

    /* 挂载文件系统 */
    rc = littlefs_mount(mp);
    if (rc < 0) {
        LOG(EERROR, "fs mount failed.");
        return -1;
    }

    /* 检索文件系统的信息,返回文件系统中的总空间和可用空间。 */
    rc = fs_statvfs(mp->mnt_point, &sbuf);
    if (rc < 0) {
        LOG(EERROR, "FAIL: statvfs: %d", rc);
        return -1;
    }

    LOG(EDEBUG, "%s: bsize = %lu ; frsize = %lu ;"
           " blocks = %lu ; bfree = %lu",
           mp->mnt_point,
           sbuf.f_bsize, sbuf.f_frsize,
           sbuf.f_blocks, sbuf.f_bfree);

    /* 检索文件系统的目录 */
    rc = lsdir(mp->mnt_point);
    if (rc < 0) {
        LOG(EERROR, "FAIL: lsdir %s: %d\n", mp->mnt_point, rc);
    }

    if (rc == 0) is_init=true;
    return rc;
}


int user_flash_uninit(void)
{
    /* 卸载文件系统 */
    int rc = fs_unmount(mp);
    LOG(EDEBUG, "%s unmount: %d", mp->mnt_point, rc);

    return rc;
}
  • 1
  • 2
  • 3
  • 4
  • 5
  • 6
  • 7
  • 8
  • 9
  • 10
  • 11
  • 12
  • 13
  • 14
  • 15
  • 16
  • 17
  • 18
  • 19
  • 20
  • 21
  • 22
  • 23
  • 24
  • 25
  • 26
  • 27
  • 28
  • 29
  • 30
  • 31
  • 32
  • 33
  • 34
  • 35
  • 36
  • 37
  • 38
  • 39
  • 40
  • 41
  • 42
  • 43
  • 44
  • 45
  • 46
  • 47
  • 48
  • 49
  • 50
  • 51
  • 52
  • 53
  • 54
  • 55
  • 56
  • 57
  • 58
  • 59
  • 60
  • 61
  • 62
  • 63
  • 64
  • 65
  • 66
  • 67
  • 68
  • 69
  • 70
  • 71
  • 72
  • 73
  • 74
  • 75
  • 76
  • 77
  • 78
  • 79
  • 80
  • 81
  • 82
  • 83
  • 84
  • 85
  • 86
  • 87
  • 88
  • 89
  • 90
  • 91
  • 92
  • 93
  • 94
  • 95
  • 96
  • 97
  • 98
  • 99
  • 100
  • 101
  • 102
  • 103
  • 104
  • 105
  • 106
  • 107
  • 108
  • 109
  • 110
  • 111
  • 112
  • 113
  • 114
  • 115
  • 116
  • 117
  • 118
  • 119
  • 120
  • 121
  • 122
  • 123
  • 124
  • 125
  • 126
  • 127
  • 128
  • 129
  • 130
  • 131
  • 132
  • 133
  • 134
  • 135
  • 136
  • 137
  • 138
  • 139
  • 140
  • 141
  • 142
  • 143
  • 144
  • 145
  • 146
  • 147
  • 148
  • 149
  • 150
  • 151
  • 152
  • 153
  • 154
  • 155
  • 156
  • 157
  • 158
  • 159
  • 160
  • 161
  • 162
  • 163
  • 164
  • 165
  • 166
  • 167
  • 168
  • 169
  • 170
  • 171
  • 172
  • 173
  • 174
  • 175
  • 176
  • 177
  • 178
  • 179
  • 180
  • 181
  • 182
  • 183
  • 184
  • 185
  • 186
  • 187
  • 188
  • 189
  • 190
  • 191
  • 192
  • 193
  • 194
  • 195
  • 196
  • 197
  • 198
  • 199

以上代码编译后,串口打印的结果如下,说明读写数据正常:

[flash_core.c littlefs_flash_erase:26][EDEBUG]Area 0 at 0x700000 on FLASH_CTRL for 1048576 bytes

[flash_core.c littlefs_mount:81][EDEBUG]/lfs1 automounted

[flash_core.c user_flash_init:172][EDEBUG]/lfs1: bsize = 16 ; frsize = 4096 ; blocks = 256 ; bfree = 253
[flash_core.c lsdir:125][EDEBUG]Listing dir /lfs1 ...
[flash_core.c lsdir:141][EDEBUG][FILE] boot_count (size = 1)
[flash_core.c lsdir:141][EDEBUG][FILE] pattern.bin (size = 547)
[flash_core.c lsdir:141][EDEBUG][FILE] test.txt (size = 36)
[flash_test.c flash_test:75][EDEBUG]read from /lfs1/test.txt, data:this is a flash read/write test data
  • 1
  • 2
  • 3
  • 4
  • 5
  • 6
  • 7
  • 8
  • 9
  • 10

接下来重新实现下shell\_command.c文件,使得命令行支持连接成功wifi后,保存到flash,命令如下

wifi connect ssid passwd 1
  • 1

其中最后的1表示save保存到Flash,0表示不保存到Flash。

shell\_command.c的改造如下:

#include <cJSON_user_define.h>

static int cmd_write_to_flash(const char* pathname, const char* data)
{
    int rc;
    ssize_t size;
    struct fs_file_t file = {0};    /*!< 初始化结构体 */    

    // 初始化flash
    user_flash_init();

    // 写入文件
    rc = fs_open(&file, pathname, FS_O_CREATE | FS_O_WRITE);
    if (rc < 0) {
        LOG(EERROR, "FAIL: open %s: %d", pathname, rc);
        return -1;
    }

    /* 写入文件 */
    size = fs_write(&file, data, strlen(data));
    if (size < 0) {
        LOG(EERROR, "FAIL: write %s: %d", pathname, rc);
        goto out;
    }

    LOG(EDEBUG, "write %s to file %s successed", data, pathname);

out:
    fs_close(&file);
    return rc;
}


/* connect 指令处理函数,附带wifi连接的参数 */
static int cmd_wifi_connect(const struct shell *shell, 
                            size_t argc, char **argv)
{
    int rc = 0;
    char json_data[100] = {0};
    int data_len = sizeof(json_data);

    LOG(EDEBUG, "cmd_wifi_connect ssid: %s psw: %s save: %d \n", 
        argv[1], argv[2], atoi(argv[3]));

    // 判断是否已经初始化Wi-Fi模块
    if (!wifi.connect_status) {
        if (WIFI_OK != wifi.init()) {
            LOG(EERROR, "wifi init failed");
            return -1;
        }
        // 连接AP热点
        if (WIFI_OK != wifi.connect_ap(argv[1], argv[2])) {
            LOG(EERROR, "wifi connect ap ssid:%s failed", argv[1]);
            return -1;
        }
    }

    // 判断是否需要把SSID和Password保存到Flash中
    if (atoi(argv[3]) == 1) {
        // 构造json数据,格式为
        // {
        //     "ssid": "xxxxxx",
        //     "passwd": "xxxxxx"
        // }
        JSON_SERIALIZE_CREATE_OBJECT_START(json_root_obj);
        JSON_SERIALIZE_ADD_STRING_TO_OBJECT(json_root_obj, "ssid", argv[1]);
        JSON_SERIALIZE_ADD_STRING_TO_OBJECT(json_root_obj, "passwd", argv[2]);
        JSON_SERIALIZE_STRING(json_root_obj, json_data, data_len);
        JSON_SERIALIZE_CREATE_END(json_root_obj);

        rc = cmd_write_to_flash(SHELL_COMMAND_WIFI_AP_INFO_PATHNAME, 
                                json_data);
        if (0 != rc) {
            LOG(EERROR, "can not write %s to %s", 
                json_data, SHELL_COMMAND_WIFI_AP_INFO_PATHNAME);
        }
    }

    return rc;
}

/// @brief 添加wifi命令的子命令(connect)
SHELL_STATIC_SUBCMD_SET_CREATE(init_deinit_command,
SHELL_CMD(connect, NULL, 
    "<ssid> <pwd> <save>, example:mimi 12345678 1", 
    cmd_wifi_connect),
SHELL_SUBCMD_SET_END /* Array terminated. */
);

/// @brief 添加WiFi命令集的根命令
SHELL_CMD_REGISTER(wifi, &init_deinit_command, "wifi command", NULL);

void shell_command_init(void)
{
#if defined(CONFIG_USB_UART_CONSOLE)
    const struct device *dev;
    uint32_t dtr = 0;
    
    /* 获取Shell设备实例 */
    dev = DEVICE_DT_GET(DT_CHOSEN(zephyr_shell_uart));
    if (!device_is_ready(dev) || usb_enable(NULL)) {
        LOG(EDEBUG, "device is not ready");
        return;
    }

    while (!dtr) {
        uart_line_ctrl_get(dev, UART_LINE_CTRL_DTR, &dtr);
        k_sleep(K_MSEC(100));
    }
#endif
}
  • 1
  • 2
  • 3
  • 4
  • 5
  • 6
  • 7
  • 8
  • 9
  • 10
  • 11
  • 12
  • 13
  • 14
  • 15
  • 16
  • 17
  • 18
  • 19
  • 20
  • 21
  • 22
  • 23
  • 24
  • 25
  • 26
  • 27
  • 28
  • 29
  • 30
  • 31
  • 32
  • 33
  • 34
  • 35
  • 36
  • 37
  • 38
  • 39
  • 40
  • 41
  • 42
  • 43
  • 44
  • 45
  • 46
  • 47
  • 48
  • 49
  • 50
  • 51
  • 52
  • 53
  • 54
  • 55
  • 56
  • 57
  • 58
  • 59
  • 60
  • 61
  • 62
  • 63
  • 64
  • 65
  • 66
  • 67
  • 68
  • 69
  • 70
  • 71
  • 72
  • 73
  • 74
  • 75
  • 76
  • 77
  • 78
  • 79
  • 80
  • 81
  • 82
  • 83
  • 84
  • 85
  • 86
  • 87
  • 88
  • 89
  • 90
  • 91
  • 92
  • 93
  • 94
  • 95
  • 96
  • 97
  • 98
  • 99
  • 100
  • 101
  • 102
  • 103
  • 104
  • 105
  • 106
  • 107
  • 108
  • 109
  • 110
  • 111

改造后,编译烧录代码,在串口终端执行wifi connect ssid passwd 1命令后的打印结果如下:

uart:~$
  clear    device   devmem   flash    help     history  kernel   pwm
  resize   shell    wifi
uart:~$ wifi connect mimi 12348765 1
[shell_command.c cmd_wifi_connect:75][EDEBUG]cmd_wifi_connect ssid: mimi psw: 12348765 save: 1

[shell_command.c cmd_wifi_connect:98][EDEBUG]start json
[shell_command.c cmd_wifi_connect:104][EDEBUG]json data:{"ssid":"mimi","passwd":"xxxxxxxx"}
[shell_command.c cmd_write_to_flash:59][EDEBUG]write {"ssid":"mimi","passwd":"xxxxxxxx"} to file /lfs/wifi.conf successed
  • 1
  • 2
  • 3
  • 4
  • 5
  • 6
  • 7
  • 8
  • 9

可以看到,成功写入了flash,设备掉电后,就可以读取Flash中保存的wifi信息进行自动联网啦

需要注意的是,这里使用了json格式保存到Flash中的,使用的是cJson库,并对cJson做了封装。本章节所有的代码实现可能不能一一介绍,最后统一放到文章最后提供。

5、Wi-Fi测速及实用场景评测

考虑到有Wi-Fi模块,本来想把之前通过USB显示的AI识别信息,使用视频流的方式通过Wi-Fi模块发送到网页端显示,这样就不用多连接一根USB线了。

但是这个对Wi-Fi的速率有要求,已经考虑实用场景可能会传输音视频,那么传输的稳定性也需要有保障。接下来是测试过程。

首先lisa zep create提供了weboscket的案例,但是websocket服务并不是太好搭建,咱们可以使用比较常用的tcp和udp服务器工具作为测试验证。类似这种:

我这里在sdk中找了一个udp的测试案例,代码在csk-sdk/zephyr/samples/net/sockets/tcp,因为是本地测速验证,使用了udp验证。

先说结论,测试的udp速度为220KB/s左右,使用的手机热点,靠近ESP32-C3模块没什么遮挡的情况下。另外测试代码为在主线程每隔1ms向网络发送一个udp报文,报文的长度是1024字节。你要问我为什么是这个长度,为什么要延迟,暂时我也不太清楚 ,原因是如果不做延迟处理,主线程会阻塞,不会发送数据出去,另外如果不使用1024,系统会崩溃,这个可能是我CONFIG配置堆栈大小的问题,由于时间不多了,小姐姐多次和我提到deadline,这个后续再继续优化验证。以下提供下测试的代码和CONFIG配置

udp\_test.c

void udp_test()
{
    const char* send_msg = "this is a udp test message.";
    const char* server_ip = "192.168.43.66";
    const short server_port = 8888;
    struct sockaddr_in addr = {0};

    // 等待Wi-Fi连接成功,实际实用场景使用信号量等阻塞方式,不使用下面这种轮询占用CPU资源
    while(!wifi.connect_status) { delay_ms(100); }

    // 初始化本地socket,获取套接字描述符
    int fd = socket(AF_INET, SOCK_DGRAM, 0);
    if (fd < 0) {
        LOG(EERROR, "socket init failed");
        return ;
    }

    LOG(EDEBUG, "udp init successed");
    
    // 配置UDP服务端IP和端口号
    net_sin((struct sockaddr *)&addr)->sin_family = AF_INET;
    net_sin((struct sockaddr *)&addr)->sin_port = htons(server_port);
    inet_pton(AF_INET, server_ip, &net_sin((struct sockaddr *)&addr)->sin_addr);

    // 速度测试
    for (;;) {
        ssize_t len = sendto(fd, message, 1024, 0, 
                            (struct sockaddr*)&addr, sizeof(addr));
        if (len < 0) {
            LOG(EERROR, "socket send failed, errorcode:%d", len);
        }
        delay_ms(1);
    }
}
  • 1
  • 2
  • 3
  • 4
  • 5
  • 6
  • 7
  • 8
  • 9
  • 10
  • 11
  • 12
  • 13
  • 14
  • 15
  • 16
  • 17
  • 18
  • 19
  • 20
  • 21
  • 22
  • 23
  • 24
  • 25
  • 26
  • 27
  • 28
  • 29
  • 30
  • 31
  • 32
  • 33
  • 34

虽然以上测试不太规范,但是220KB/s的速度已经可以传输双声道16K 16bit的PCM数据没有问题了,如果是图像帧数据的应用场景,需要使用网络传输的话,就需要考虑怎样优化了,实际情况ESP32-C3代码和天线方案优化好了,肯定不止220KB/s,按照之前经验,实际测试应该能到800-900KB/s的速率,传输一些低码率的视频流应该也是可以的了。

本实验也遇到了一个严重的问题,就是一旦加入网络通信的配置,当烧录完程序后,会有固定30秒的延迟才会执行到main函数,不知道是为什么,打开了CONFIG\\_WIFI\\_LOG\\_LEVEL\\_DBG=y也没有看到相关的打印日志。

6、基础SDK评测——LED亮灭闪烁呼吸

硬件产品离不开指示灯,指示灯一般有亮、灭、闪烁和呼吸效果,使用SDK+定时器实现了LED的这些功能,测试代码如下:

#include <zephyr/kernel.h>
#include <delay.h>
#include <led_core.h>

void led_test()
{
    led.on(GREEN_LED);
    delay_ms(1000);
    
    led.off(GREEN_LED);
    delay_ms(1000);

    // 绿色LED每隔100ms闪烁一次,共闪烁5秒钟的时间
    led.blink(GREEN_LED, 100, 3000);
    delay_ms(3000);
    // 绿色LED每隔1000ms呼吸一次,共呼吸5秒钟的时间
    led.breathing(GREEN_LED, 1000, 0);
}
  • 1
  • 2
  • 3
  • 4
  • 5
  • 6
  • 7
  • 8
  • 9
  • 10
  • 11
  • 12
  • 13
  • 14
  • 15
  • 16
  • 17
  • 18

代码实现如下(亮灭部分可以使用GPIO的方式)

led\_core.h的内容如下:

#ifndef _LED_SENSOR_H
#define _LED_SENSOR_H

/// \brief LED的类型,目前板载只有绿色LED
typedef enum {
    GREEN_LED,
} led_type_t;


/// \brief LED灯状态,有开关、闪烁和呼吸4个状态
///
/// 其中的绿色LED不支持呼吸灯的效果,使用时需注意
typedef enum {
    LED_OFF         = 1,
    LED_ON          = 2,
    LED_BLINK       = 3,
    LED_BREATHING   = 4,
}led_status_t;


/// \brief LED灯当前信息
typedef struct {
    led_type_t      led_type;
    led_status_t    led_status;
    int             interval;
    int             duration;
    int             current_interval;
    int             current_duration;
    int             pwm_flap;
    int             pwm_cnt;
}led_info_t;


/**
 * @brief
 *  LED灯的亮灭、闪烁、呼吸的控制接口
 */
typedef struct {
    /*!< @brief 绿色LED信息 */
    led_info_t      green_led;

    /*!< @brief LED周期 */
    int             period;

    /// LED闪烁定时器
    void*           blink_timer;
    /// LED呼吸定时器
    void*           breathe_timer;
    
    /**
    * @brief 点亮LED灯
    *
    * @param led 要被点亮的led灯,可以是red_led和green_led
    * @return
    */
    void (*on)(led_type_t led_type);
    
    /**
    * @brief 熄灭LED灯
    *
    * @param led 要被熄灭的led灯,可以是red_led和green_led
    * @return
    */
    void (*off)(led_type_t led_type);
    
    /**
    * @brief LED灯闪烁
    *
    * @param led 闪烁的led灯,可以是red_led和green_led
    * @param interval 闪烁间隔时间,以毫秒为单位
    * @param duration 灯闪烁或呼吸持续的时间,以毫秒为单位,开关LED不关心这个值
    * @return
    */
    void (*blink)(led_type_t led_type, int interval, int duration);
    
    /**
    * @brief LED呼吸灯
    *
    * @param led 呼吸的led灯,注意仅red_led支持呼吸灯
    * @param interval 灯间隔时间,以毫秒为单位,开关LED不关心这个值
    * @param duration 灯闪烁或呼吸持续的时间,以毫秒为单位,开关LED不关心这个值
    * @return
    */
    void (*breathing)(led_type_t led_type, int interval, int duration);

}led_core_t;


/*! @brief led灯单例 */
extern led_core_t led;

#endif
  • 1
  • 2
  • 3
  • 4
  • 5
  • 6
  • 7
  • 8
  • 9
  • 10
  • 11
  • 12
  • 13
  • 14
  • 15
  • 16
  • 17
  • 18
  • 19
  • 20
  • 21
  • 22
  • 23
  • 24
  • 25
  • 26
  • 27
  • 28
  • 29
  • 30
  • 31
  • 32
  • 33
  • 34
  • 35
  • 36
  • 37
  • 38
  • 39
  • 40
  • 41
  • 42
  • 43
  • 44
  • 45
  • 46
  • 47
  • 48
  • 49
  • 50
  • 51
  • 52
  • 53
  • 54
  • 55
  • 56
  • 57
  • 58
  • 59
  • 60
  • 61
  • 62
  • 63
  • 64
  • 65
  • 66
  • 67
  • 68
  • 69
  • 70
  • 71
  • 72
  • 73
  • 74
  • 75
  • 76
  • 77
  • 78
  • 79
  • 80
  • 81
  • 82
  • 83
  • 84
  • 85
  • 86
  • 87
  • 88
  • 89
  • 90
  • 91
  • 92

led\_core.c的内容如下:

#include <zephyr/zephyr.h>
#include <zephyr/kernel.h>
#include <zephyr/device.h>
#include <zephyr/drivers/gpio.h>
#include <zephyr/drivers/pwm.h>
#include <log.h>
#include <delay.h>
#include <led_core.h>

#define LED_DEFAULT_INTERVAL 20U
#define LED_MIN_PERIOD PWM_MSEC(1U)
#define LED_MAX_PERIOD PWM_MSEC(LED_DEFAULT_INTERVAL)

#define LED_TIMER_INIT(timer, timer_expiry, timer_stop) \
    static struct k_timer timer; \
    k_timer_init(&timer, timer_expiry, timer_stop); \
    led.timer = (void*)&timer;

/* 通过别名获取 "led0" 设备树 node id */
#define LED0_NODE DT_ALIAS(led0)

/* 通过 node id 获取 led0 设备树信息 */
static const struct gpio_dt_spec led0 = GPIO_DT_SPEC_GET(LED0_NODE, gpios);

/* 获取设备树配置 */
static const struct pwm_dt_spec pwm_led0 = PWM_DT_SPEC_GET(DT_ALIAS(pwm_led0));

/// 接口实现声明
__weak void csk6011a_nano_led_on(led_type_t led_type);
__weak void csk6011a_nano_led_off(led_type_t led_type);
__weak void csk6011a_nano_led_blink(led_type_t led_type, 
                                        int interval, 
                                        int duration);
__weak void csk6011a_nano_led_breathing(led_type_t led_type, 
                                        int interval, 
                                        int duration);

// led单例初始化
led_core_t led = {
    .green_led      = {GREEN_LED, LED_OFF, 0, 0, 0, 0, 0, 0},
    .on             = csk6011a_nano_led_on,
    .off            = csk6011a_nano_led_off,
    .blink          = csk6011a_nano_led_blink,
    .breathing      = csk6011a_nano_led_breathing,
    .period         = LED_MAX_PERIOD,
};


static void blink_timer_expiry(struct k_timer *timer)
{
    /* 关闭LED闪烁 */
    csk6011a_nano_led_off(GREEN_LED);
    // LOG(EDEBUG, "blink_timer_expiry called");
}


static void blink_timer_stop(struct k_timer *timer)
{
    // LOG(EDEBUG, "blink_timer_stop called");
}


static void breathe_timer_expiry(struct k_timer *timer)
{
    if(led.green_led.pwm_flap)led.green_led.pwm_cnt -= led.green_led.interval;
    else led.green_led.pwm_cnt += led.green_led.interval;
    
    if(led.green_led.pwm_cnt >= led.period) {
        led.green_led.pwm_cnt = led.period - 1;
        led.green_led.pwm_flap = 1;
    }
    if(led.green_led.pwm_cnt <= 0) {
        led.green_led.pwm_cnt = 1;
        led.green_led.pwm_flap = 0;
    }

    pwm_set_dt(&pwm_led0, led.period, led.green_led.pwm_cnt);
    
    if (led.green_led.duration > 0) {
        led.green_led.current_duration += LED_DEFAULT_INTERVAL;
        if (led.green_led.current_duration >= led.green_led.duration) {
            k_timer_stop((struct k_timer *)led.breathe_timer);
        }
    }
}


static void breathe_timer_stop(struct k_timer *timer)
{
    // LOG(EDEBUG, "breathe_timer_stop");
}


static void csk6011a_nano_led_init(void)
{
    static bool is_init = false;
    if (!is_init) {
        if (!device_is_ready(led0.port)) {
            LOG(EERROR, "led0 is not ready");
            return ;
        }
        if (!device_is_ready(pwm_led0.dev)) {
            LOG(EERROR, "Error: PWM device %s is not ready",
                pwm_led0.dev->name);
            return ;
        }
        /* 配置LED闪烁定时器 */
        LED_TIMER_INIT(blink_timer, blink_timer_expiry, blink_timer_stop);
        /* 配置LED呼吸灯定时器 */
        LED_TIMER_INIT(breathe_timer, 
            breathe_timer_expiry, breathe_timer_stop);
        is_init = true;
    }
}


__weak void csk6011a_nano_led_on(led_type_t led_type)
{
    csk6011a_nano_led_init();

    if (led_type == GREEN_LED) {
        pwm_set_dt(&pwm_led0, led.period, 1);
        // TODO: 按照以下设置后,无法使用新的周期设置占空比,后续使用pwm_cycles_to_usec重新初始化尝试
        // pwm_set_dt(&pwm_led0, PWM_SEC(1U), 1);
        // gpio_pin_configure_dt(&led0, GPIO_OUTPUT_INACTIVE);
    }
}


__weak void csk6011a_nano_led_off(led_type_t led_type)
{
    csk6011a_nano_led_init();

    if (led_type == GREEN_LED) {
        pwm_set_dt(&pwm_led0, led.period, led.period-1);
        // pwm_set_dt(&pwm_led0, PWM_SEC(1U), PWM_SEC(1U)-1);
        // gpio_pin_configure_dt(&led0, GPIO_OUTPUT_ACTIVE);
    }
}


__weak void csk6011a_nano_led_blink(led_type_t led_type, 
                                    int interval, 
                                    int duration)
{
    csk6011a_nano_led_init();

    if (led_type == GREEN_LED) {
        k_timer_stop((struct k_timer *)led.blink_timer);
        k_timer_stop((struct k_timer *)led.breathe_timer);
        pwm_set_dt(&pwm_led0, PWM_MSEC(interval), PWM_MSEC(interval)/2);
        if (duration > 0) {
            k_timer_start((struct k_timer *)led.blink_timer, 
                K_MSEC(duration), K_MSEC(0));
        }
    }
}


__weak void csk6011a_nano_led_breathing(led_type_t led_type, 
                                        int interval, 
                                        int duration)
{
    csk6011a_nano_led_init();

    if (led_type == GREEN_LED) {
        led.green_led.led_status = LED_BREATHING;
        led.green_led.interval = 
            (int)((1000.0/interval)*(led.period/(1000/LED_DEFAULT_INTERVAL)));
        led.green_led.duration = duration;
        led.green_led.current_duration = 0;
        led.green_led.current_interval = 0;
        led.green_led.pwm_flap = 0;
        led.green_led.pwm_cnt = 0;

        k_timer_stop((struct k_timer *)led.blink_timer);
        k_timer_stop((struct k_timer *)led.breathe_timer);
        k_timer_start((struct k_timer *)led.breathe_timer, 
            K_MSEC(LED_DEFAULT_INTERVAL), K_MSEC(LED_DEFAULT_INTERVAL));
    }
}
  • 1
  • 2
  • 3
  • 4
  • 5
  • 6
  • 7
  • 8
  • 9
  • 10
  • 11
  • 12
  • 13
  • 14
  • 15
  • 16
  • 17
  • 18
  • 19
  • 20
  • 21
  • 22
  • 23
  • 24
  • 25
  • 26
  • 27
  • 28
  • 29
  • 30
  • 31
  • 32
  • 33
  • 34
  • 35
  • 36
  • 37
  • 38
  • 39
  • 40
  • 41
  • 42
  • 43
  • 44
  • 45
  • 46
  • 47
  • 48
  • 49
  • 50
  • 51
  • 52
  • 53
  • 54
  • 55
  • 56
  • 57
  • 58
  • 59
  • 60
  • 61
  • 62
  • 63
  • 64
  • 65
  • 66
  • 67
  • 68
  • 69
  • 70
  • 71
  • 72
  • 73
  • 74
  • 75
  • 76
  • 77
  • 78
  • 79
  • 80
  • 81
  • 82
  • 83
  • 84
  • 85
  • 86
  • 87
  • 88
  • 89
  • 90
  • 91
  • 92
  • 93
  • 94
  • 95
  • 96
  • 97
  • 98
  • 99
  • 100
  • 101
  • 102
  • 103
  • 104
  • 105
  • 106
  • 107
  • 108
  • 109
  • 110
  • 111
  • 112
  • 113
  • 114
  • 115
  • 116
  • 117
  • 118
  • 119
  • 120
  • 121
  • 122
  • 123
  • 124
  • 125
  • 126
  • 127
  • 128
  • 129
  • 130
  • 131
  • 132
  • 133
  • 134
  • 135
  • 136
  • 137
  • 138
  • 139
  • 140
  • 141
  • 142
  • 143
  • 144
  • 145
  • 146
  • 147
  • 148
  • 149
  • 150
  • 151
  • 152
  • 153
  • 154
  • 155
  • 156
  • 157
  • 158
  • 159
  • 160
  • 161
  • 162
  • 163
  • 164
  • 165
  • 166
  • 167
  • 168
  • 169
  • 170
  • 171
  • 172
  • 173
  • 174
  • 175
  • 176
  • 177
  • 178
  • 179
  • 180
  • 181

7、基础SDK评测——Button软件消抖

BUTTON的原理图

按照以上原理图无法保证机械抖动产生的异常中断,产生的现象就是你以为只产生一次中断,但实际可能是多次,导致程序可能误判,导致执行逻辑错误。

解决的办法就是软件消抖,比较好的处理方式就是定时器,这个需要多次尝试,看示波器波形,看第一次抖动到最后一次抖动的最长间隔时间,设置一个最大的定时器值就可以了,这里使用的是10ms,保证只执行到最后一次中断产生的定时器,则任务按键中断发生的原理。

测试代码如下,屏蔽了dts的配置,方便应用人员开发应用逻辑。

#include <log.h>
#include <key_core.h>
#include <user_test.h>

void on_key_event(key_event_t event, void* user_data)
{
    switch(event) {
        case KEY_EVENT_RELEASE:
            LOG(EDEBUG, "key is released.");
            break;
        case KEY_EVENT_PRESSED:
            LOG(EDEBUG, "key is pressed.");
            break;
        case KEY_EVENT_LONG_PRESSED:
            LOG(EDEBUG, "key is long pressed.");
            break;
        default:
            LOG(EDEBUG, "key event is error.");
            break;
    }
}

void key_test()
{
    // 经过消抖的按键按下、抬起、长按功能测试
    key.init();
    key.event_register(on_key_event, NULL);
}
  • 1
  • 2
  • 3
  • 4
  • 5
  • 6
  • 7
  • 8
  • 9
  • 10
  • 11
  • 12
  • 13
  • 14
  • 15
  • 16
  • 17
  • 18
  • 19
  • 20
  • 21
  • 22
  • 23
  • 24
  • 25
  • 26
  • 27
  • 28

具体实现由key\\_core.c/h完成,key\\_core.h内容如下。

#ifndef KEY_CORE_H
#define KEY_CORE_H

#include <stdbool.h>

/// 按键消抖延迟事件,根据实际硬件情况更改,机械按键推荐5-10毫秒
#define KEY_DEJITTER_TIME       10
/// 长按事件的时间间隔
#define KYE_LONG_PRESSED_TIME   3000

/// \brief 支持的按键事件
typedef enum {
    KEY_EVENT_RELEASE,
    KEY_EVENT_PRESSED,
    KEY_EVENT_LONG_PRESSED,
} key_event_t;

/// 定义按键的事件回调函数指针
typedef void (*on_key_event_t)(key_event_t event, void* user_data);

/**
 * @brief 按键用户核心层接口
 * 
 * @details
 *  实现了按键的引脚配置,使用init完成;
 *  实现了按键的事件注册,支持的事件
 */
typedef struct key_ {

    /// 记录按键当前的状态
    key_event_t         event;

    /// 引脚配置句柄
    void*               gpio_handle;

    /// 按键消抖定时器
    void*               dejitter_timer;
    /// 长按判断定时器
    void*               long_pressed_timer;

    /// 按键事件回调函数指针
    on_key_event_t      on_event;
    void*               user_data;   

    /**
     * @brief 按键初始化
     * 
     * @return  成功返回true, 失败返回false
     */
    bool (*init)(void);

    /**
     * @brief 按键逆初始化
     * 
     */
    void (*uninit)(void);

    /**
     * @brief: 注册按键事件
     * 
     * @param on_key_event 按键事件
     * @param user_data 向按键回调提供的用户私有数据
     * 
     * @return 成功返回true,识别返回false
     */
    bool (*event_register)(on_key_event_t on_key_event, void* user_data);

    /**
     * @brief: 反注册按键事件
     * 
     */
    void (*event_unregister)(void);

} user_key_t;

/*!< 按键单例 */
extern user_key_t key;

#endif
  • 1
  • 2
  • 3
  • 4
  • 5
  • 6
  • 7
  • 8
  • 9
  • 10
  • 11
  • 12
  • 13
  • 14
  • 15
  • 16
  • 17
  • 18
  • 19
  • 20
  • 21
  • 22
  • 23
  • 24
  • 25
  • 26
  • 27
  • 28
  • 29
  • 30
  • 31
  • 32
  • 33
  • 34
  • 35
  • 36
  • 37
  • 38
  • 39
  • 40
  • 41
  • 42
  • 43
  • 44
  • 45
  • 46
  • 47
  • 48
  • 49
  • 50
  • 51
  • 52
  • 53
  • 54
  • 55
  • 56
  • 57
  • 58
  • 59
  • 60
  • 61
  • 62
  • 63
  • 64
  • 65
  • 66
  • 67
  • 68
  • 69
  • 70
  • 71
  • 72
  • 73
  • 74
  • 75
  • 76
  • 77
  • 78
  • 79

key\_core.c内容如下:

#include <zephyr/zephyr.h>
#include <zephyr/kernel.h>
#include <zephyr/device.h>
#include <zephyr/drivers/gpio.h>
#include <log.h>

#include <key_core.h>

#define SW0_NODE    DT_ALIAS(sw0)
#if !DT_NODE_HAS_STATUS(SW0_NODE, okay)
#error "Unsupported board: sw0 devicetree alias is not defined"
#endif

static const struct gpio_dt_spec button = GPIO_DT_SPEC_GET_OR(SW0_NODE, gpios,
                                     {0});
static struct gpio_callback button_cb_data;

/// 接口实现声明
__weak bool csk6011a_nano_key_init(void);
__weak void csk6011a_nano_key_uninit(void);
__weak bool csk6011a_nano_key_event_register(on_key_event_t on_key_event, 
                                            void* user_data);
__weak void csk6011a_nano_key_event_unregister(void);

/// @brief 按键单例初始化
user_key_t key = {
    .event              = KEY_EVENT_RELEASE,    /*!< 初始化引脚为抬起状态 */
    .init               = csk6011a_nano_key_init,
    .uninit             = csk6011a_nano_key_uninit,
    .event_register     = csk6011a_nano_key_event_register,
    .event_unregister   = csk6011a_nano_key_event_unregister,
    .gpio_handle        = (void*)&button,       /*!< 初始化引脚配置的句柄 */
};


static void dejitter_timer_expiry(struct k_timer *timer)
{
    /* 更新电平状态 */
    int level = gpio_pin_get_dt(&button);
    if (level == 0) key.event = KEY_EVENT_RELEASE;
    else if (level == 1) key.event = KEY_EVENT_PRESSED;

    /* 上报用户按键事件 */
    if (key.on_event) {
        key.on_event(key.event, key.user_data);
    }

    // LOG(EDEBUG, "Button pressed at %u, level:%d", 
    //         k_cycle_get_32(), gpio_pin_get_dt(&button));
}


static void dejitter_timer_stop(struct k_timer *timer)
{
    // LOG(EDEBUG, "dejitter_timer_stop");
}


static void long_pressed_timer_expiry(struct k_timer *timer)
{
    /* 上报用户按键事件 */
    key.event = KEY_EVENT_LONG_PRESSED;
    if (key.on_event) {
        key.on_event(key.event, key.user_data);
    }
}


static void long_pressed_timer_stop(struct k_timer *timer)
{
    // LOG(EDEBUG, "long_pressed_timer_stop");
}


static void button_pressed(const struct device *dev, struct gpio_callback *cb,
        uint32_t pins)
{
    /* 长按判断逻辑,按键按下启动长按定时器,抬起关闭 */
    int level = gpio_pin_get_dt(&button);
    if (level == 0) {
        k_timer_stop((struct k_timer *)key.long_pressed_timer);
    }
    else if (level == 1) {
        k_timer_start((struct k_timer *)key.long_pressed_timer, K_MSEC(KYE_LONG_PRESSED_TIME), K_MSEC(0));
    }
    
    /* 触发定时器, 使用10ms间隔进行按键消抖,定时器只触发一次 */
    k_timer_start((struct k_timer *)key.dejitter_timer, K_MSEC(10), K_MSEC(0));
}


__weak bool csk6011a_nano_key_init()
{
    int32_t ret;
    
    /* 检查硬件设备是否就绪 */
    if (!device_is_ready(button.port)) {
        LOG(EERROR, "Error: key %s is not ready", button.port->name);
        return false;
    }

    /* 配置引脚功能为输入模式 */
    ret = gpio_pin_configure_dt(&button, GPIO_INPUT | GPIO_PULL_DOWN);
    if (ret != 0) {
        LOG(EERROR, "Error %d: failed to configure %s pin %d",
            ret, button.port->name, button.pin);
        return false;
    }

    /* 配置引脚中断模式,支持上升沿和下降沿触发 */
    ret = gpio_pin_interrupt_configure_dt(&button, GPIO_INT_EDGE_BOTH);
    if (ret != 0) {
        LOG(EERROR, "Error %d: failed to configure interrupt on %s pin %d",
            ret, button.port->name, button.pin);
        return false;
    }
    
    /* 配置按键定时器 */
    static struct k_timer dejitter_timer;
    k_timer_init(&dejitter_timer, dejitter_timer_expiry, dejitter_timer_stop);
    key.dejitter_timer = (void*)&dejitter_timer;

    /* 配置按键定时器 */
    static struct k_timer long_pressed_timer;
    k_timer_init(&long_pressed_timer, long_pressed_timer_expiry, long_pressed_timer_stop);
    key.long_pressed_timer = (void*)&long_pressed_timer;

    /* 根据原理图设置默认电平状态,保存按键设备树操作接口 */
    key.event = 
        (gpio_pin_get_dt(&button) == 1)?KEY_EVENT_PRESSED:KEY_EVENT_RELEASE;
    key.gpio_handle = (void*)&button;

    return true;
}


__weak void csk6011a_nano_key_uninit()
{
    k_timer_stop((struct k_timer *)key.dejitter_timer);
    k_timer_stop((struct k_timer *)key.long_pressed_timer);
    key.init                = NULL;
    key.uninit              = NULL;
    key.event_register      = NULL;
    key.event_unregister    = NULL;
    key.gpio_handle         = NULL;
    key.event               = KEY_EVENT_RELEASE;
}


__weak bool csk6011a_nano_key_event_register(on_key_event_t on_key_event, 
                                    void* user_data)
{
    int32_t ret;

    /* 初始化中断回调 */
    gpio_init_callback(&button_cb_data, button_pressed, BIT(button.pin));
    /* 注册回调事件 */
    ret = gpio_add_callback(button.port, &button_cb_data);
    LOG(EDEBUG, "Set up button at %s pin %d", button.port->name, button.pin);

    key.on_event = on_key_event;
    key.user_data = user_data;

    return (ret==0)?true:false;
}


__weak void csk6011a_nano_key_event_unregister()
{
    gpio_remove_callback(button.port, &button_cb_data);
}
  • 1
  • 2
  • 3
  • 4
  • 5
  • 6
  • 7
  • 8
  • 9
  • 10
  • 11
  • 12
  • 13
  • 14
  • 15
  • 16
  • 17
  • 18
  • 19
  • 20
  • 21
  • 22
  • 23
  • 24
  • 25
  • 26
  • 27
  • 28
  • 29
  • 30
  • 31
  • 32
  • 33
  • 34
  • 35
  • 36
  • 37
  • 38
  • 39
  • 40
  • 41
  • 42
  • 43
  • 44
  • 45
  • 46
  • 47
  • 48
  • 49
  • 50
  • 51
  • 52
  • 53
  • 54
  • 55
  • 56
  • 57
  • 58
  • 59
  • 60
  • 61
  • 62
  • 63
  • 64
  • 65
  • 66
  • 67
  • 68
  • 69
  • 70
  • 71
  • 72
  • 73
  • 74
  • 75
  • 76
  • 77
  • 78
  • 79
  • 80
  • 81
  • 82
  • 83
  • 84
  • 85
  • 86
  • 87
  • 88
  • 89
  • 90
  • 91
  • 92
  • 93
  • 94
  • 95
  • 96
  • 97
  • 98
  • 99
  • 100
  • 101
  • 102
  • 103
  • 104
  • 105
  • 106
  • 107
  • 108
  • 109
  • 110
  • 111
  • 112
  • 113
  • 114
  • 115
  • 116
  • 117
  • 118
  • 119
  • 120
  • 121
  • 122
  • 123
  • 124
  • 125
  • 126
  • 127
  • 128
  • 129
  • 130
  • 131
  • 132
  • 133
  • 134
  • 135
  • 136
  • 137
  • 138
  • 139
  • 140
  • 141
  • 142
  • 143
  • 144
  • 145
  • 146
  • 147
  • 148
  • 149
  • 150
  • 151
  • 152
  • 153
  • 154
  • 155
  • 156
  • 157
  • 158
  • 159
  • 160
  • 161
  • 162
  • 163
  • 164
  • 165
  • 166
  • 167
  • 168
  • 169
  • 170
  • 171

烧录后,短按、长按不再会出现抖动,并且支持短按和长按的动作识别:

[key_test.c on_key_event:12][EDEBUG]key is pressed.
[key_test.c on_key_event:15][EDEBUG]key is long pressed.
[key_test.c on_key_event:9][EDEBUG]key is released.
  • 1
  • 2
  • 3

8、问题

【轻微】在开发实践->外设驱动->PWM的使用示例的原理图下面,控制引脚是GPIOA\\_06改为GPIOB\\_06;

【轻微】Wi-Fi配网的文档问题,在开发实践->系统服务->网络->WIFI连接 编译和烧录->编译中使用了lisa zep build -b csk6002\\_9s\\_nano版型,实际通过验证lisa zep build -b csk6011a\\_c3\\_nano编译通过;

【一般】提供图像识别、语音识别、Wi-Fi的综合应用的案例,评估芯片综合应用场景下的能力。因为目前提供的AI Sample都是独立的Sample并会下载单独的SDK,不知道和一开始安装在系统上的SDK的差异,遇到问题不好对比跟踪;

【严重】涉及网络的SDK在使用时,需要等待固定30秒钟才能进入main函数;

9、期待

  • 提供AI的开放能力,支持通用的一些框架和模型;
  • 提供HIFI DSP相关的能力,期望能有多麦克风阵列+回采的方案,支持声源定位的功能,虽然画面的头肩识别、人脸识别可以完成定位,有些场景也需要多模态识别进行位置信息获取和处理;

10、本案例源码

本案例的源代码已经上传到了github,有兴趣的可以下载体验:

git clone https://github.com/aibittek/HsdVideoStreamAPP.git
  • 1

通过项目名称可以看出来,其实一开始想做一个视频流应用的,就不用多插入一个USB线预览AI识别结果了,可惜有两个问题还不确定原因,详见问题部分的说明,解决后再把这块完善下吧

声明:本文内容由网友自发贡献,不代表【wpsshop博客】立场,版权归原作者所有,本站不承担相应法律责任。如您发现有侵权的内容,请联系我们。转载请注明出处:https://www.wpsshop.cn/w/AllinToyou/article/detail/720340
推荐阅读
相关标签
  

闽ICP备14008679号