政安晨的个人主页:政安晨
欢迎 👍点赞✍评论⭐收藏
希望政安晨的博客能够对您有所裨益,如有不足之处,欢迎在评论区提出指正!
这是笔者尝试应用小智AI终端的经验之谈,也算是一次基于小智AI终端应用开发的复盘。
目录
小智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嵌入式终端开发中的相关经验。
谢谢各位小伙伴的阅览,祝工作愉快。(你热爱它,它就是一件很好玩的事。嘻嘻。)