政安晨【开源人工智能硬件】【ESP乐鑫篇】 —— 详细分享小智(78/xiaozhi-esp32)AI终端开源硬件的嵌入式开发经验笔记

发布于:2025-08-06 ⋅ 阅读:(17) ⋅ 点赞:(0)

政安晨的个人主页:政安晨

欢迎 👍点赞✍评论⭐收藏

希望政安晨的博客能够对您有所裨益,如有不足之处,欢迎在评论区提出指正!

 这是笔者尝试应用小智AI终端的经验之谈,也算是一次基于小智AI终端应用开发的复盘。

目录

评价一下小智项目的意义

经验一:选好合适的IDF版本

经验二:了解其软件架构

经验三:系统崩溃的时候你不要崩溃

结束语


小智AI(ESP32)的项目地址:https://github.com/78/xiaozhi-esp32https://github.com/78/xiaozhi-esp32

评价一下小智项目的意义

小智AI项目的技术架构特点

小智AI项目以低成本、高效率的ESP系列芯片为核心,结合轻量化AI算法,实现了语音交互、设备控制等智能化功能。其技术栈包括ESP-IDF开发框架、Opus组件、MCP服务组件等工具链,显著降低了AI模型在微控制器上的部署门槛。

对硬件选型的借鉴价值

ESP芯片的Wi-Fi/蓝牙双模集成、低功耗特性与小智AI的场景需求高度契合。项目验证了ESP32-C3等型号在实时性要求较高的语音处理任务中的可行性,为同类产品提供了硬件选型参考,尤其适合智能家居、可穿戴设备等低成本场景。

算法优化与边缘计算的实践意义

小智AI通过模型量化、剪枝等技术将AI模型应用压缩,解决了ESP芯片内存限制问题。这一方案证明边缘端AI推理可在资源受限设备上运行,减少了云端依赖,为隐私敏感型应用(如家庭安防)提供了技术路径。

开发效率与生态整合的启示

项目采用模块化设计,将传感器驱动、通信协议等封装为可复用组件,缩短了开发周期。其开源社区贡献的代码库(如语音唤醒固件)可直接适配同类ESP产品,加速了开发者生态的协同创新。

成本控制与量产可行性

小智AI的BOM成本控制在20美元以内,证明了ESP芯片在大规模量产中的经济性优势。其硬件设计(如PCB天线优化方案)和OTA升级机制,为智能硬件产品的可维护性提供了标准化模板。

用户场景适配的创新思路

项目通过离线语音指令识别和场景联动规则引擎,解决了网络不稳定环境下的用户体验问题。这种“轻智能”设计对农业物联网、工业边缘节点等特殊场景具有参考价值。

(注:以上分析基于公开技术文档及ESP官方案例,实际开发需结合实际情况。)

小智AI框架的出现帮助广大开发者打通了乐鑫方案产品化的快捷路径,但想要完全掌握小智AI终端框架却不是那么容易,对开发者还是有一定的门槛要求:

1. 对C/C++语言要熟练掌握(如果仅靠AI编程帮你解决嵌入式智能硬件开发工作,那恐怕你会踩很多坑。)

2. 要基本了解MCU(含RTOS系统)的基本运行原理。

3.要懂一点硬件电路的知识,包括射频通信等。

4. 要有产品化思维,知道怎样测试、怎样发现问题、怎样解决问题。

等等。

小智的开源项目中有一个关于MCP协议多终端控制的语音交互入口图:

基本操作本篇经验笔记中不多谈,我们将在这里重点关注某些相对比较关键的问题。

经验一:选好合适的IDF版本

小智的不同版本对IDF的本版要求是对应的,代码更新迭代也比较快,这部分要多留意。

## IDF Component Manager Manifest File
dependencies:
  waveshare/esp_lcd_sh8601: 1.0.2
  espressif/esp_lcd_ili9341: ==1.2.0
  espressif/esp_lcd_gc9a01: ==2.0.1
  espressif/esp_lcd_st77916: ^1.0.1
  espressif/esp_lcd_axs15231b: ^1.0.0
  espressif/esp_lcd_st7796:
    version: 1.3.2
    rules:
    - if: target not in [esp32c3]
  espressif/esp_lcd_spd2010: ==1.0.2
  espressif/esp_io_expander_tca9554: ==2.0.0
  espressif/esp_lcd_panel_io_additions: ^1.0.1
  78/esp_lcd_nv3023: ~1.0.0
  78/esp-wifi-connect: ~2.4.3
  78/esp-opus-encoder: ~2.4.0
  78/esp-ml307: ~3.2.5
  78/xiaozhi-fonts: ~1.3.2
  espressif/led_strip: ^2.5.5
  espressif/esp_codec_dev: ~1.3.6
  espressif/esp-sr: ~2.1.1
  espressif/button: ~4.1.3
  espressif/knob: ^1.0.0
  espressif/esp32-camera: ^2.0.15
  espressif/esp_lcd_touch_ft5x06: ~1.0.7
  espressif/esp_lcd_touch_gt911: ^1
  waveshare/esp_lcd_touch_cst9217: ^1.0.3
  espressif/esp_lcd_touch_cst816s: ^1.0.6
  lvgl/lvgl: ~9.2.2
  esp_lvgl_port: ~2.6.0
  espressif/esp_io_expander_tca95xx_16bit: ^2.0.0
  espressif2022/image_player: ==1.1.0~1
  espressif2022/esp_emote_gfx: ^1.0.0
  espressif/adc_mic: ^0.2.0
  espressif/esp_mmap_assets: '>=1.2'
  txp666/otto-emoji-gif-component: ~1.0.2
  espressif/adc_battery_estimation: ^0.2.0

  # SenseCAP Watcher Board
  wvirgil123/esp_jpeg_simd:
    version: 1.0.0
    rules:
    - if: target in [esp32s3]
  wvirgil123/sscma_client:
    version: 1.0.2
    rules:
    - if: target in [esp32s3]

  tny-robotics/sh1106-esp-idf:
    version: ^1.0.0
    rules:
    - if: idf_version >= "5.4.0"

  waveshare/esp_lcd_jd9365_10_1:
    version: '*'
    rules:
    - if: target in [esp32p4]
  waveshare/esp_lcd_st7703:
    version: '*'
    rules:
    - if: target in [esp32p4]
  espressif/esp_lcd_ili9881c:
    version: ^1.0.1
    rules:
    - if: target in [esp32p4]
  espressif/esp_hosted:
    version: '2.0.17'
    rules:
    - if: target in [esp32h2, esp32p4]
  espressif/esp_wifi_remote:
    version: '*'
    rules:
    - if: target in [esp32p4]
  espfriends/servo_dog_ctrl:
    version: ^0.1.8
    rules:
    - if: target in [esp32c3]

  ## Required IDF version
  idf:
    version: '>=5.4.0'

基于自己硬件的情况在boards文件夹下创建型号产品,你可以参考box开发板的配置。

这个里面的坑也还是属于基本操作,自己探索。

IO口配置正确,电池电量的读取,顺着代码往下看,看不懂不要急,从app.Start()开始看:

    // Launch the application
    auto& app = Application::GetInstance();
    app.Start();
    app.MainEventLoop();
}

经验二:了解其软件架构

当你仔细阅读Application::Start()的时候你会发现,整套软件好像都是用c++写的,如果你以前是熟悉C的,你可能不太适应。其实,你再仔细看一下就会发现:C++仅是用来对应用方法的封装,核心实现还是基于C的。

void Application::Start() {
    auto& board = Board::GetInstance();
    SetDeviceState(kDeviceStateStarting);

    /* Setup the display */
    auto display = board.GetDisplay();

    /* Setup the audio service */
    auto codec = board.GetAudioCodec();
    audio_service_.Initialize(codec);
    audio_service_.Start();

    AudioServiceCallbacks callbacks;
    callbacks.on_send_queue_available = [this]() {
        xEventGroupSetBits(event_group_, MAIN_EVENT_SEND_AUDIO);
    };
    callbacks.on_wake_word_detected = [this](const std::string& wake_word) {
        xEventGroupSetBits(event_group_, MAIN_EVENT_WAKE_WORD_DETECTED);
    };
    callbacks.on_vad_change = [this](bool speaking) {
        xEventGroupSetBits(event_group_, MAIN_EVENT_VAD_CHANGE);
    };
    audio_service_.SetCallbacks(callbacks);

    /* Start the clock timer to update the status bar */
    esp_timer_start_periodic(clock_timer_handle_, 1000000);

    /* Wait for the network to be ready */
    board.StartNetwork();

    // Update the status bar immediately to show the network state
    display->UpdateStatusBar(true);

    // Check for new firmware version or get the MQTT broker address
    Ota ota;
    CheckNewVersion(ota);

    // Initialize the protocol
    display->SetStatus(Lang::Strings::LOADING_PROTOCOL);

    // Add MCP common tools before initializing the protocol
    McpServer::GetInstance().AddCommonTools();

    if (ota.HasMqttConfig()) {
        protocol_ = std::make_unique<MqttProtocol>();
    } else if (ota.HasWebsocketConfig()) {
        protocol_ = std::make_unique<WebsocketProtocol>();
    } else {
        ESP_LOGW(TAG, "No protocol specified in the OTA config, using MQTT");
        protocol_ = std::make_unique<MqttProtocol>();
    }

    protocol_->OnNetworkError([this](const std::string& message) {
        last_error_message_ = message;
        xEventGroupSetBits(event_group_, MAIN_EVENT_ERROR);
    });
    protocol_->OnIncomingAudio([this](std::unique_ptr<AudioStreamPacket> packet) {
        if (device_state_ == kDeviceStateSpeaking) {
            audio_service_.PushPacketToDecodeQueue(std::move(packet));
        }
    });
    protocol_->OnAudioChannelOpened([this, codec, &board]() {
        board.SetPowerSaveMode(false);
        if (protocol_->server_sample_rate() != codec->output_sample_rate()) {
            ESP_LOGW(TAG, "Server sample rate %d does not match device output sample rate %d, resampling may cause distortion",
                protocol_->server_sample_rate(), codec->output_sample_rate());
        }
    });
    protocol_->OnAudioChannelClosed([this, &board]() {
        board.SetPowerSaveMode(true);
        Schedule([this]() {
            auto display = Board::GetInstance().GetDisplay();
            display->SetChatMessage("system", "");
            SetDeviceState(kDeviceStateIdle);
        });
    });
    protocol_->OnIncomingJson([this, display](const cJSON* root) {
        // Parse JSON data
        auto type = cJSON_GetObjectItem(root, "type");
        if (strcmp(type->valuestring, "tts") == 0) {
            auto state = cJSON_GetObjectItem(root, "state");
            if (strcmp(state->valuestring, "start") == 0) {
                Schedule([this]() {
                    aborted_ = false;
                    if (device_state_ == kDeviceStateIdle || device_state_ == kDeviceStateListening) {
                        SetDeviceState(kDeviceStateSpeaking);
                    }
                });
            } else if (strcmp(state->valuestring, "stop") == 0) {
                Schedule([this]() {
                    if (device_state_ == kDeviceStateSpeaking) {
                        if (listening_mode_ == kListeningModeManualStop) {
                            SetDeviceState(kDeviceStateIdle);
                        } else {
                            SetDeviceState(kDeviceStateListening);
                        }
                    }
                });
            } else if (strcmp(state->valuestring, "sentence_start") == 0) {
                auto text = cJSON_GetObjectItem(root, "text");
                if (cJSON_IsString(text)) {
                    ESP_LOGI(TAG, "<< %s", text->valuestring);
                    Schedule([this, display, message = std::string(text->valuestring)]() {
                        display->SetChatMessage("assistant", message.c_str());
                    });
                }
            }
        } else if (strcmp(type->valuestring, "stt") == 0) {
            auto text = cJSON_GetObjectItem(root, "text");
            if (cJSON_IsString(text)) {
                ESP_LOGI(TAG, ">> %s", text->valuestring);
                Schedule([this, display, message = std::string(text->valuestring)]() {
                    display->SetChatMessage("user", message.c_str());
                });
            }
        } else if (strcmp(type->valuestring, "llm") == 0) {
            auto emotion = cJSON_GetObjectItem(root, "emotion");
            if (cJSON_IsString(emotion)) {
                Schedule([this, display, emotion_str = std::string(emotion->valuestring)]() {
                    display->SetEmotion(emotion_str.c_str());
                });
            }
        } else if (strcmp(type->valuestring, "mcp") == 0) {
            auto payload = cJSON_GetObjectItem(root, "payload");
            if (cJSON_IsObject(payload)) {
                McpServer::GetInstance().ParseMessage(payload);
            }
        } else if (strcmp(type->valuestring, "system") == 0) {
            auto command = cJSON_GetObjectItem(root, "command");
            if (cJSON_IsString(command)) {
                ESP_LOGI(TAG, "System command: %s", command->valuestring);
                if (strcmp(command->valuestring, "reboot") == 0) {
                    // Do a reboot if user requests a OTA update
                    Schedule([this]() {
                        Reboot();
                    });
                } else {
                    ESP_LOGW(TAG, "Unknown system command: %s", command->valuestring);
                }
            }
        } else if (strcmp(type->valuestring, "alert") == 0) {
            auto status = cJSON_GetObjectItem(root, "status");
            auto message = cJSON_GetObjectItem(root, "message");
            auto emotion = cJSON_GetObjectItem(root, "emotion");
            if (cJSON_IsString(status) && cJSON_IsString(message) && cJSON_IsString(emotion)) {
                Alert(status->valuestring, message->valuestring, emotion->valuestring, Lang::Sounds::P3_VIBRATION);
            } else {
                ESP_LOGW(TAG, "Alert command requires status, message and emotion");
            }
#if CONFIG_RECEIVE_CUSTOM_MESSAGE
        } else if (strcmp(type->valuestring, "custom") == 0) {
            auto payload = cJSON_GetObjectItem(root, "payload");
            ESP_LOGI(TAG, "Received custom message: %s", cJSON_PrintUnformatted(root));
            if (cJSON_IsObject(payload)) {
                Schedule([this, display, payload_str = std::string(cJSON_PrintUnformatted(payload))]() {
                    display->SetChatMessage("system", payload_str.c_str());
                });
            } else {
                ESP_LOGW(TAG, "Invalid custom message format: missing payload");
            }
#endif
        } else {
            ESP_LOGW(TAG, "Unknown message type: %s", type->valuestring);
        }
    });
    bool protocol_started = protocol_->Start();

    SetDeviceState(kDeviceStateIdle);

    has_server_time_ = ota.HasServerTime();
    if (protocol_started) {
        std::string message = std::string(Lang::Strings::VERSION) + ota.GetCurrentVersion();
        display->ShowNotification(message.c_str());
        display->SetChatMessage("system", "");
        // Play the success sound to indicate the device is ready
        audio_service_.PlaySound(Lang::Sounds::P3_SUCCESS);
    }

    // Print heap stats
    SystemInfo::PrintHeapStats();
}

小智这套软件的基本思想是将业务逻辑剥离出来,在main文件夹的一级目录中,这些文件主要有:

application.cc

mcp_server.cc

ota.cc

核心应用业务逻辑就在上述3个文件中,再在audio文件夹中实现音频处理的逻辑,diplay文件夹中实现显示处理的逻辑,protocols文件夹中实现通信逻辑的应用封装,并尽可能地把硬件有关的变化及操作封闭在boards中,这就是最主要的思想,并用面向对象的类和方法固化下来,形成可扩展的基本框架。

 在这个基础上,在各自业务逻辑中,类的方法里调用乐鑫IDF的接口(C语言实现),发现IDF的某些接口无法适配,或者第三方协议栈或库更好用的时候,使用外置组件封装,这些都在managed_componets中,其中有乐鑫开发的,也有78(虾哥)开发的,大家看代码的时候带着managed_componets看是有益处的。

其实,架构说到这里,就基本是全部了,对于懂得小伙伴,上面两段话很重要。接下来就是一些实践经验了。

经验三:系统崩溃的时候你不要崩溃

开发嵌入式系统,尤其是基于开源框架开发嵌入式系统,内存泄露等引起的系统崩溃是家常便饭,大家要习惯和适应,刚开始的时候可以将ESP的调试功能打开,观察内存泄露的过程,比如引用的空handle,重复释放内存,临时数组在新的进程周期内失效等,都可能是你经常面临的事情。

尤其是这套框架迭代周期快,BUG一定是层出不穷的,不要慌。

使用小智的开源项目时,尽量下载打了版本tag的代码(如 v x.x.x )这样的代码经过了基本测试,固化了版本,相对比较可靠。

同时,手头要有至少2个版本的软件做对比测试,出问题的时候,跑一跑上一版软件试试看。

小智的新版服务地址是从OTA接口拿的:


bool Ota::CheckVersion() {
    auto& board = Board::GetInstance();
    auto app_desc = esp_app_get_description();

    // Check if there is a new firmware version available
    current_version_ = app_desc->version;
    ESP_LOGI(TAG, "Current version: %s", current_version_.c_str());

    std::string url = GetCheckVersionUrl();
    if (url.length() < 10) {
        ESP_LOGE(TAG, "Check version URL is not properly set");
        return false;
    }

    auto http = SetupHttp();

    //http->SetTimeout(10000); // 10秒超时//add by zachen

    std::string data = board.GetJson();
    std::string method = data.length() > 0 ? "POST" : "GET";
    http->SetContent(std::move(data));

    if (!http->Open(method, url)) {
        ESP_LOGE(TAG, "Failed to open HTTP connection");
        return false;
    }

    vTaskDelay(pdMS_TO_TICKS(200));// add by zachen

    auto status_code = http->GetStatusCode();
    if (status_code != 200) {
        ESP_LOGE(TAG, "Failed to check version, status code: %d", status_code);
        http->Close(); //add by zachen
        return false;
    }

    data = http->ReadAll();
    http->Close();

    ///* //zachen
    // 添加空数据检查
    if (data.empty()) {
        ESP_LOGE(TAG, "Received empty response from server");
        return false;
    }

    ESP_LOGI(TAG, "Received response: %s", data.c_str());
    //*/

    // Response: { "firmware": { "version": "1.0.0", "url": "http://" } }
    // Parse the JSON response and check if the version is newer
    // If it is, set has_new_version_ to true and store the new version and URL
    
    cJSON *root = cJSON_Parse(data.c_str());
    if (root == NULL) {
        ESP_LOGE(TAG, "Failed to parse JSON response");
        return false;
    }

    has_activation_code_ = false;
    has_activation_challenge_ = false;
    cJSON *activation = cJSON_GetObjectItem(root, "activation");
    if (cJSON_IsObject(activation)) {
        cJSON* message = cJSON_GetObjectItem(activation, "message");
        if (cJSON_IsString(message)) {
            activation_message_ = message->valuestring;
        }
        cJSON* code = cJSON_GetObjectItem(activation, "code");
        if (cJSON_IsString(code)) {
            activation_code_ = code->valuestring;
            has_activation_code_ = true;
        }
        cJSON* challenge = cJSON_GetObjectItem(activation, "challenge");
        if (cJSON_IsString(challenge)) {
            activation_challenge_ = challenge->valuestring;
            has_activation_challenge_ = true;
        }
        cJSON* timeout_ms = cJSON_GetObjectItem(activation, "timeout_ms");
        if (cJSON_IsNumber(timeout_ms)) {
            activation_timeout_ms_ = timeout_ms->valueint;
        }
    }

    has_mqtt_config_ = false;
    cJSON *mqtt = cJSON_GetObjectItem(root, "mqtt");
    if (cJSON_IsObject(mqtt)) {
        Settings settings("mqtt", true);
        cJSON *item = NULL;
        cJSON_ArrayForEach(item, mqtt) {
            if (item != NULL && item->string != NULL) {
                if (cJSON_IsString(item)) {
                    if (settings.GetString(item->string) != item->valuestring) {
                        settings.SetString(item->string, item->valuestring);
                    }
                } else if (cJSON_IsNumber(item)) {
                    if (settings.GetInt(item->string) != item->valueint) {
                        settings.SetInt(item->string, item->valueint);
                    }
                }
            }
        }
        has_mqtt_config_ = true;
    } else {
        ESP_LOGI(TAG, "No mqtt section found !");
    }

    has_websocket_config_ = false;
    cJSON *websocket = cJSON_GetObjectItem(root, "websocket");
    if (cJSON_IsObject(websocket)) {
        Settings settings("websocket", true);
        cJSON *item = NULL;
        cJSON_ArrayForEach(item, websocket) {
            if (item != NULL && item->string != NULL) {
                if (cJSON_IsString(item)) {
                    if (settings.GetString(item->string) != item->valuestring) {
                        settings.SetString(item->string, item->valuestring);
                    }
                } else if (cJSON_IsNumber(item)) {
                    if (settings.GetInt(item->string) != item->valueint) {
                        settings.SetInt(item->string, item->valueint);
                    }
                }
            }
        }
        has_websocket_config_ = true;
    } else {
        ESP_LOGI(TAG, "No websocket section found!");
    }

    has_server_time_ = false;
    cJSON *server_time = cJSON_GetObjectItem(root, "server_time");
    if (cJSON_IsObject(server_time)) {
        cJSON *timestamp = cJSON_GetObjectItem(server_time, "timestamp");
        cJSON *timezone_offset = cJSON_GetObjectItem(server_time, "timezone_offset");
        
        if (cJSON_IsNumber(timestamp)) {
            // 设置系统时间
            struct timeval tv;
            double ts = timestamp->valuedouble;
            
            // 如果有时区偏移,计算本地时间
            if (cJSON_IsNumber(timezone_offset)) {
                ts += (timezone_offset->valueint * 60 * 1000); // 转换分钟为毫秒
            }
            
            tv.tv_sec = (time_t)(ts / 1000);  // 转换毫秒为秒
            tv.tv_usec = (suseconds_t)((long long)ts % 1000) * 1000;  // 剩余的毫秒转换为微秒
            settimeofday(&tv, NULL);
            has_server_time_ = true;
        }
    } else {
        ESP_LOGW(TAG, "No server_time section found!");
    }

    has_new_version_ = false;
    cJSON *firmware = cJSON_GetObjectItem(root, "firmware");
    if (cJSON_IsObject(firmware)) {
        cJSON *version = cJSON_GetObjectItem(firmware, "version");
        if (cJSON_IsString(version)) {
            firmware_version_ = version->valuestring;
        }
        cJSON *url = cJSON_GetObjectItem(firmware, "url");
        if (cJSON_IsString(url)) {
            firmware_url_ = url->valuestring;
        }

        if (cJSON_IsString(version) && cJSON_IsString(url)) {
            // Check if the version is newer, for example, 0.1.0 is newer than 0.0.1
            has_new_version_ = IsNewVersionAvailable(current_version_, firmware_version_);
            if (has_new_version_) {
                ESP_LOGI(TAG, "New version available: %s", firmware_version_.c_str());
            } else {
                ESP_LOGI(TAG, "Current is the latest version");
            }
            // If the force flag is set to 1, the given version is forced to be installed
            cJSON *force = cJSON_GetObjectItem(firmware, "force");
            if (cJSON_IsNumber(force) && force->valueint == 1) {
                has_new_version_ = true;
            }
        }
    } else {
        ESP_LOGW(TAG, "No firmware section found!");
    }

    cJSON_Delete(root);
    return true;
}

这个接口在初始化的时候如果有时候出现死机,可以考虑像我上面的代码那样增加一个延时,基本可以解决OTA初始化死机问题。


结束语

时间有限,我先分享这些经验,今后会陆续在这篇文章中更新小智AI嵌入式终端开发中的相关经验。

谢谢各位小伙伴的阅览,祝工作愉快。(你热爱它,它就是一件很好玩的事。嘻嘻。)


网站公告

今日签到

点亮在社区的每一天
去签到