feat(assets): embed LCD and audio resources in OTA app

This commit is contained in:
chenjintao
2026-10-08 16:58:33 +08:00
parent 3160a33f50
commit 7bc9a3dc76
43 changed files with 443 additions and 243 deletions
+3 -3
View File
@@ -1,10 +1,10 @@
# ESP-IDF Partition Table
# Name, Type, SubType, Offset, Size, Flags
# PNG/Opus 素材已链接进应用镜像,两块 OTA 应用分区均需容纳完整固件和素材。
nvs, data, nvs, 0x9000, 0x4000,
otadata, data, ota, 0xd000, 0x2000,
phy_init, data, phy, 0xf000, 0x1000,
gifpack, data, littlefs, , 0x200000,
ota_0, app, ota_0, , 0x380000,
ota_1, app, ota_1, , 0x380000,
ota_0, app, ota_0, , 0x480000,
ota_1, app, ota_1, , 0x480000,
model, data, spiffs, , 0x300000,
coredump, data, coredump,, 0x10000,
1 # ESP-IDF Partition Table
2 # Name, Type, SubType, Offset, Size, Flags
3 # PNG/Opus 素材已链接进应用镜像,两块 OTA 应用分区均需容纳完整固件和素材。
4 nvs, data, nvs, 0x9000, 0x4000,
5 otadata, data, ota, 0xd000, 0x2000,
6 phy_init, data, phy, 0xf000, 0x1000,
7 gifpack, data, littlefs, , 0x200000, ota_0, app, ota_0, , 0x480000,
8 ota_0, app, ota_0, , 0x380000, ota_1, app, ota_1, , 0x480000,
ota_1, app, ota_1, , 0x380000,
9 model, data, spiffs, , 0x300000,
10 coredump, data, coredump,, 0x10000,
+13
View File
@@ -20,3 +20,16 @@ add_custom_target(versioned_firmware ALL
COMMENT "Creating versioned firmware ${CMAKE_PROJECT_NAME}-${TQ_PROJECT_VERSION}.bin"
VERBATIM
)
# 依赖应用和模型产物,确保每次构建输出的是本次镜像的分区及空间数据。
add_custom_target(flash_usage_report ALL
COMMAND ${PYTHON} "${CMAKE_SOURCE_DIR}/tools/report_flash_usage.py"
--partition-table "${CMAKE_BINARY_DIR}/partition_table/partition-table.bin"
--app "${CMAKE_BINARY_DIR}/${CMAKE_PROJECT_NAME}.bin"
--asset-pack "${CMAKE_BINARY_DIR}/esp-idf/platform/platform_assets.pack"
--model "${CMAKE_BINARY_DIR}/srmodels/srmodels.bin"
--flash-size "${CONFIG_ESPTOOLPY_FLASHSIZE}"
DEPENDS versioned_firmware srmodels_bin
COMMENT "Reporting Flash partition and embedded asset usage"
VERBATIM
)
@@ -24,7 +24,7 @@
#endif
#ifndef CONFIG_TQ_LOCAL_WAKE_RESPONSE_PROMPT_FILE
#define CONFIG_TQ_LOCAL_WAKE_RESPONSE_PROMPT_FILE "local_wake_response.opus"
#define CONFIG_TQ_LOCAL_WAKE_RESPONSE_PROMPT_FILE "audio_prompts/local_wake_response.opus"
#endif
#ifndef CONFIG_TQ_LOCAL_WAKE_RETRY_MS
@@ -55,7 +55,7 @@
* 2 秒则给播放器正常收尾机会,超过后才按停滞恢复,避免网络抖动造成频繁强停。
*/
#define CONTROL_PLANE_WATCHDOG_TASK_STACK_SIZE CONFIG_TQ_MULTIMODAL_WATCHDOG_TASK_STACK_BYTES
#define CONTROL_PLANE_SERVER_SUCCESS_PROMPT_FILE "/littlefs/audio_prompts/server_connect_success.opus"
#define CONTROL_PLANE_SERVER_SUCCESS_PROMPT_FILE "audio_prompts/server_connect_success.opus"
#define CONTROL_PLANE_CLIENT_LIFECYCLE_WAIT_MS 30000U
#define CONTROL_PLANE_SERVER_SUCCESS_PROMPT_RETRY_MS 1000U
#define CONTROL_PLANE_SERVER_SUCCESS_PROMPT_MAX_ATTEMPTS 3U
@@ -42,14 +42,14 @@
* 多次确认用于排除负载突变导致的瞬时低压;紧急阈值只需两次以缩短保护延迟。
* 轻待机下采样间隔显著拉长,充电 GPIO 中断仍可及时唤醒状态更新。
*/
#define BATTERY_LOW_PROMPT_FILE "/littlefs/audio_prompts/battery_low_20.opus"
#define BATTERY_FULL_PROMPT_FILE "/littlefs/audio_prompts/charge_complete.opus"
#define BATTERY_LOW_PROMPT_FILE "audio_prompts/battery_low_20.opus"
#define BATTERY_FULL_PROMPT_FILE "audio_prompts/charge_complete.opus"
#ifndef CONFIG_TQ_BATTERY_CONTROLLED_SHUTDOWN_ENABLE
#define CONFIG_TQ_BATTERY_CONTROLLED_SHUTDOWN_ENABLE 0
#endif
#ifndef CONFIG_TQ_BATTERY_SHUTDOWN_PROMPT_FILE
#define CONFIG_TQ_BATTERY_SHUTDOWN_PROMPT_FILE "/littlefs/audio_prompts/shutdown_power_empty.opus"
#define CONFIG_TQ_BATTERY_SHUTDOWN_PROMPT_FILE "audio_prompts/shutdown_power_empty.opus"
#endif
#if CONFIG_TQ_BATTERY_CONTROLLED_SHUTDOWN_ENABLE
@@ -55,7 +55,7 @@
#define CONFIG_TQ_MINI_PROGRAM_PROVISIONING_MIN_LARGEST_DMA_BYTES 12288
#endif
#define CONTROL_PLANE_WIFI_SUCCESS_PROMPT_FILE "/littlefs/audio_prompts/network_connect_success.opus"
#define CONTROL_PLANE_WIFI_SUCCESS_PROMPT_FILE "audio_prompts/network_connect_success.opus"
#define CONTROL_PLANE_STA_CONNECT_MAX_ATTEMPTS 3
#define CONTROL_PLANE_STA_RETRY_BASE_DELAY_MS 300
@@ -52,7 +52,7 @@
#endif
#ifndef CONFIG_TQ_LIGHT_STANDBY_PROMPT_FILE
#define CONFIG_TQ_LIGHT_STANDBY_PROMPT_FILE "standby_5min.opus"
#define CONFIG_TQ_LIGHT_STANDBY_PROMPT_FILE "audio_prompts/standby_5min.opus"
#endif
#ifndef CONFIG_TQ_LIGHT_STANDBY_DISABLE_SPEAKER_OUTPUT
@@ -25,15 +25,15 @@
/* 3 秒宽限过滤 WebSocket 短重连,5 秒冷却防止 Wi-Fi 与服务端事件交替造成提示音轰炸。 */
#define CONTROL_PLANE_WIFI_INTERRUPTED_PROMPT_FILE \
"/littlefs/audio_prompts/network_interrupted_repairing.opus"
"audio_prompts/network_interrupted_repairing.opus"
#define CONTROL_PLANE_WIFI_ACTION_BLOCKED_PROMPT_FILE \
"/littlefs/audio_prompts/network_unstable.opus"
"audio_prompts/network_unstable.opus"
#define CONTROL_PLANE_SERVER_INTERRUPTED_PROMPT_FILE \
"/littlefs/audio_prompts/server_abnormal_interrupted.opus"
"audio_prompts/server_abnormal_interrupted.opus"
#define CONTROL_PLANE_SERVER_ACTION_BLOCKED_PROMPT_FILE \
"/littlefs/audio_prompts/server_repair_wait_reopen.opus"
"audio_prompts/server_repair_wait_reopen.opus"
#define CONTROL_PLANE_NETWORK_RESTORED_PROMPT_FILE \
"/littlefs/audio_prompts/signal_restored_business_resume.opus"
"audio_prompts/signal_restored_business_resume.opus"
typedef enum {
NETWORK_FEEDBACK_STATE_BOOTING = 0,
@@ -43,7 +43,7 @@
#define CONTROL_PLANE_PTT_HOLD_STEP_MS 80
/* 长按 PTT 以 80 ms 小步等待,既能及时感知释放,也能周期检查会话已失败或被取消。 */
#define CONTROL_PLANE_MUYU_PROMPT_FILE "muyu.opus"
#define CONTROL_PLANE_MUYU_PROMPT_FILE "audio_prompts/muyu.opus"
#ifndef CONFIG_TQ_LOCAL_TASK_CORE
#define CONFIG_TQ_LOCAL_TASK_CORE 1
-1
View File
@@ -336,7 +336,6 @@ esp_err_t domain_runtime_slot_feature_switch_init(void);
bool domain_runtime_slot_feature_switch_is_active(void);
esp_err_t domain_runtime_prepare_storage(void);
esp_err_t domain_runtime_mount_littlefs(void);
void domain_runtime_read_device_identity(char *device_id,
size_t device_id_len,
char *serial_number,
@@ -905,11 +905,6 @@ esp_err_t domain_runtime_prepare_storage(void)
return platform_prepare_storage();
}
esp_err_t domain_runtime_mount_littlefs(void)
{
return platform_mount_littlefs();
}
void domain_runtime_read_device_identity(char *device_id,
size_t device_id_len,
char *serial_number,
+14 -6
View File
@@ -16,6 +16,7 @@ set(platform_srcs
"src/platform_io_effects.c"
"src/lcd_direct.c"
"src/lcd_direct_assets.c"
"src/platform_asset_bundle.c"
"third_party/lodepng/lodepng.cpp"
"third_party/lodepng/lodepng_esp_alloc.cpp"
"third_party/lodepng/lodepng_esp_bridge.cpp"
@@ -54,7 +55,6 @@ set(platform_priv_requires
esp_wifi
esp_websocket_client
json
littlefs
log
espressif__mdns
mbedtls
@@ -90,6 +90,8 @@ set(platform_lcd_asset_manifest "${platform_lcd_asset_src_dir}/manifest.json")
set(platform_lcd_asset_optimizer "${CMAKE_CURRENT_SOURCE_DIR}/../../tools/optimize_lcd_assets.py")
set(platform_asset_image_dir "${CMAKE_CURRENT_BINARY_DIR}/asset_image")
set(platform_asset_stage_stamp "${CMAKE_CURRENT_BINARY_DIR}/asset_image.stamp")
set(platform_asset_pack "${CMAKE_CURRENT_BINARY_DIR}/platform_assets.pack")
set(platform_asset_packer "${CMAKE_CURRENT_SOURCE_DIR}/../../tools/pack_embedded_assets.py")
file(GLOB_RECURSE platform_audio_prompt_files CONFIGURE_DEPENDS
"${platform_audio_prompt_src_dir}/*.opus"
)
@@ -199,6 +201,7 @@ foreach(lcd_png_asset IN LISTS platform_lcd_png_assets)
)
endforeach()
# 先校验并暂存实际引用的 PNG/Opus,再由打包脚本生成应用内只读资源包。
add_custom_command(
OUTPUT "${platform_asset_stage_stamp}"
COMMAND ${PYTHON} "${platform_lcd_asset_optimizer}" check-current
@@ -214,9 +217,14 @@ add_custom_command(
"${platform_lcd_asset_optimizer}"
)
littlefs_create_partition_image(
gifpack
"${platform_asset_image_dir}"
FLASH_IN_PROJECT
DEPENDS "${platform_asset_stage_stamp}"
# 素材或打包脚本变化时重新生成包,并通过二进制链接目标纳入 OTA 应用镜像。
add_custom_command(
OUTPUT "${platform_asset_pack}"
COMMAND ${PYTHON} "${platform_asset_packer}"
"${platform_asset_image_dir}" "${platform_asset_pack}"
DEPENDS "${platform_asset_stage_stamp}" "${platform_asset_packer}"
VERBATIM
)
target_add_binary_data(${COMPONENT_LIB} "${platform_asset_pack}" BINARY
RENAME_TO platform_assets_pack)
-1
View File
@@ -1,6 +1,5 @@
dependencies:
idf: '>=4.4'
joltwallet/littlefs: 1.20.4
espressif/esp_websocket_client: ^1.6.1
espressif/esp_codec_dev: ^1.5.4
espressif/esp_audio_codec: ^2.4.1
-1
View File
@@ -221,7 +221,6 @@ typedef struct {
esp_err_t platform_init(void);
esp_err_t platform_prepare_storage(void);
bool platform_storage_is_ready(void);
esp_err_t platform_mount_littlefs(void);
void platform_read_device_identity(char *device_id,
size_t device_id_len,
char *serial_number,
@@ -7,7 +7,7 @@
extern "C" {
#endif
/* RGB565 图片描述符,实际像素由渲染任务根据路径加载和解码。 */
/* RGB565 图片描述符,实际像素由渲染任务根据逻辑名称查找和解码。 */
typedef struct {
const char *path;
} lcd_direct_image_t;
@@ -0,0 +1,14 @@
#pragma once
#include <stddef.h>
#include <stdint.h>
#include "esp_err.h"
typedef struct {
const uint8_t *data;
size_t size;
} platform_asset_view_t;
/* 按包内完整相对名称查找素材,例如 lcd/ota1.png、audio_prompts/boot.opus;成功后 data 指向当前固件的只读映射区,无需释放。 */
esp_err_t platform_asset_find(const char *name, platform_asset_view_t *out_asset);
+2 -2
View File
@@ -7,8 +7,8 @@ extern "C" {
#endif
/*
* 同步播放 LittleFS 中的 Opus 提示音。函数负责分帧、下行播放会话和完成等待,
* 返回时文件句柄已关闭;路径解析和播放超时均在实现内处理。
* 同步播放应用镜像中的 Opus 提示音。函数负责分帧、下行播放会话和完成等待;
* 素材查找和播放超时均在实现内处理。
*/
esp_err_t voice_prompt_play_file(const char *path);
+10 -60
View File
@@ -7,15 +7,13 @@
#include "lcd_direct.h"
#include "lcd_direct_assets.h"
#include "platform_asset_bundle.h"
#include <errno.h>
#include <fcntl.h>
#include <stdbool.h>
#include <stdint.h>
#include <stdlib.h>
#include <string.h>
#include <time.h>
#include <unistd.h>
#include "driver/gpio.h"
#include "driver/ledc.h"
@@ -55,7 +53,7 @@
#define LCD_DIRECT_PREHEAT_WAIT_TIMEOUT_MS 5000U
/*
* PNG 缓存固定6项并使用 LRU 淘汰,限制 PSRAM 峰值;边界测试恰好覆盖一次完整
* 缓存命中周期。路径上限同时约束缓存键和 LittleFS 资源路径复制。
* 缓存命中周期。名称上限同时约束缓存键和嵌入式素材索引。
*/
#define LCD_DIRECT_FRAME_CACHE_MAX 6U
#define LCD_DIRECT_BOUNDARY_TEST_CACHE_HIT_FRAMES LCD_DIRECT_FRAME_CACHE_MAX
@@ -316,55 +314,6 @@ static uint16_t lcd_direct_blend_rgb565(uint16_t dst, uint16_t src, uint8_t alph
return lcd_direct_rgb888_to_rgb565(out_r, out_g, out_b);
}
static esp_err_t lcd_direct_read_file_psram(const char *path, unsigned char **out_data, size_t *out_size)
{
/* 整个 PNG 文件优先读入 PSRAM;任何短读都视为失败并关闭句柄。 */
ESP_RETURN_ON_FALSE(path != NULL && out_data != NULL && out_size != NULL,
ESP_ERR_INVALID_ARG,
TAG,
"invalid PNG read request");
*out_data = NULL;
*out_size = 0;
errno = 0;
int fd = open(path, O_RDONLY);
if (fd < 0) {
ESP_LOGE(TAG, "open PNG asset failed path=%s errno=%d", path, errno);
return ESP_ERR_NOT_FOUND;
}
off_t file_size = lseek(fd, 0, SEEK_END);
if (file_size <= 0 || lseek(fd, 0, SEEK_SET) < 0) {
ESP_LOGE(TAG, "stat PNG asset failed path=%s errno=%d", path, errno);
close(fd);
return ESP_FAIL;
}
unsigned char *data = (unsigned char *)lcd_direct_alloc_psram((size_t)file_size);
if (data == NULL) {
ESP_LOGE(TAG, "allocate PNG file buffer failed path=%s size=%u", path, (unsigned)file_size);
close(fd);
return ESP_ERR_NO_MEM;
}
size_t offset = 0;
while (offset < (size_t)file_size) {
ssize_t bytes_read = read(fd, data + offset, (size_t)file_size - offset);
if (bytes_read <= 0) {
ESP_LOGE(TAG, "read PNG asset failed path=%s errno=%d", path, errno);
free(data);
close(fd);
return ESP_FAIL;
}
offset += (size_t)bytes_read;
}
close(fd);
*out_data = data;
*out_size = (size_t)file_size;
return ESP_OK;
}
static void lcd_direct_release_decoded_image(lcd_direct_decoded_image_t *decoded)
{
if (decoded == NULL) {
@@ -386,18 +335,19 @@ static esp_err_t lcd_direct_decode_png(const char *path,
"invalid PNG decode request");
memset(out_decoded, 0, sizeof(*out_decoded));
unsigned char *png_data = NULL;
size_t png_size = 0;
esp_err_t read_err = lcd_direct_read_file_psram(path, &png_data, &png_size);
if (read_err != ESP_OK) {
return read_err;
/* PNG 字节直接取自应用镜像的只读映射区;解码器只读取输入,不持有或释放该指针。 */
platform_asset_view_t asset = {0};
esp_err_t find_err = platform_asset_find(path, &asset);
if (find_err != ESP_OK) {
ESP_LOGE(TAG, "find PNG asset failed path=%s err=%s", path, esp_err_to_name(find_err));
return find_err;
}
unsigned char *rgba = NULL;
unsigned width = 0;
unsigned height = 0;
unsigned decode_err = lodepng_esp_decode32(&rgba, &width, &height, png_data, png_size);
free(png_data);
unsigned decode_err = lodepng_esp_decode32(&rgba, &width, &height,
asset.data, asset.size);
if (decode_err != 0U) {
ESP_LOGE(TAG, "decode PNG failed path=%s error=%u text=%s",
path,
+1 -1
View File
@@ -2,7 +2,7 @@
#include <string.h>
#define LCD_ASSET_BASE_PATH "/littlefs/lcd/"
#define LCD_ASSET_BASE_PATH "lcd/"
/*
* 静态表只保存资源元数据和路径,不在此文件执行 PNG 解码。
@@ -0,0 +1,76 @@
#include "platform_asset_bundle.h"
#include <stdbool.h>
#include <string.h>
#define ASSET_BUNDLE_HEADER_BYTES 12U
#define ASSET_ENTRY_HEADER_BYTES 6U
/* 素材包由构建脚本链接进应用镜像,链接符号界定只读 Flash 映射区。格式为 magic(8) + 数量(u32 LE),随后逐项存放名称长度(u16 LE)、数据长度(u32 LE)、UTF-8 名称和原始数据。 */
extern const uint8_t platform_assets_pack_start[]
asm("_binary_platform_assets_pack_start");
extern const uint8_t platform_assets_pack_end[]
asm("_binary_platform_assets_pack_end");
static uint16_t read_u16_le(const uint8_t *data)
{
return (uint16_t)data[0] | ((uint16_t)data[1] << 8);
}
static uint32_t read_u32_le(const uint8_t *data)
{
return (uint32_t)data[0] | ((uint32_t)data[1] << 8) |
((uint32_t)data[2] << 16) | ((uint32_t)data[3] << 24);
}
esp_err_t platform_asset_find(const char *name, platform_asset_view_t *out_asset)
{
if (name == NULL || out_asset == NULL || name[0] == '\0') {
return ESP_ERR_INVALID_ARG;
}
*out_asset = (platform_asset_view_t) {0};
/* 调用方必须使用包内完整相对名称,例如 audio_prompts/boot.opus。 */
const size_t name_len = strlen(name);
if (name_len == 0U) {
return ESP_ERR_INVALID_ARG;
}
const uint8_t *cursor = platform_assets_pack_start;
const uint8_t *end = platform_assets_pack_end;
if ((size_t)(end - cursor) < ASSET_BUNDLE_HEADER_BYTES ||
memcmp(cursor, "TQASSET1", 8U) != 0) {
return ESP_ERR_INVALID_RESPONSE;
}
const uint32_t count = read_u32_le(cursor + 8U);
cursor += ASSET_BUNDLE_HEADER_BYTES;
/* 每次推进游标前校验剩余字节,避免损坏的包头或条目越过链接区间。 */
for (uint32_t i = 0; i < count; ++i) {
if ((size_t)(end - cursor) < ASSET_ENTRY_HEADER_BYTES) {
return ESP_ERR_INVALID_RESPONSE;
}
const size_t stored_name_len = read_u16_le(cursor);
const size_t data_len = read_u32_le(cursor + 2U);
cursor += ASSET_ENTRY_HEADER_BYTES;
if (stored_name_len == 0U || data_len == 0U ||
stored_name_len > (size_t)(end - cursor)) {
return ESP_ERR_INVALID_RESPONSE;
}
const uint8_t *stored_name = cursor;
cursor += stored_name_len;
if (data_len > (size_t)(end - cursor)) {
return ESP_ERR_INVALID_RESPONSE;
}
const bool matches = stored_name_len == name_len &&
memcmp(stored_name, name, name_len) == 0;
if (matches) {
/* 直接返回镜像内数据视图,调用方只能读取,不能释放或写入。 */
out_asset->data = cursor;
out_asset->size = data_len;
return ESP_OK;
}
cursor += data_len;
}
return cursor == end ? ESP_ERR_NOT_FOUND : ESP_ERR_INVALID_RESPONSE;
}
@@ -4,7 +4,6 @@
* SPDX-License-Identifier: Apache-2.0
*/
#include <stdio.h>
#include <stdbool.h>
#include <stdint.h>
#include <stdlib.h>
@@ -14,10 +13,10 @@
#include "esp_err.h"
#include "esp_log.h"
#include "esp_heap_caps.h"
#include "esp_littlefs.h"
#include "freertos/FreeRTOS.h"
#include "freertos/task.h"
#include "multimodal/audio_pipeline.h"
#include "platform_asset_bundle.h"
#include "power_manager.h"
#include "sdkconfig.h"
#include "voice_prompt.h"
@@ -30,12 +29,8 @@
#error "CONFIG_TQ_POWER_HOLD_ACTIVE_LEVEL must be defined in Kconfig"
#endif
/* 提示音从 gifpack LittleFS 分区读取。 */
#define VOICE_PROMPT_DEFAULT_BASE_PATH "/littlefs/audio_prompts"
#define VOICE_PROMPT_LITTLEFS_BASE_PATH "/littlefs"
#define VOICE_PROMPT_LITTLEFS_PARTITION "gifpack"
/*
* 提示音文件由4字节小端包长头和对应 raw-Opus payload 重复组成;单包上限2KB
* 提示音由4字节大端包长头和对应 raw-Opus payload 重复组成;单包上限2KB
* 防止损坏文件触发异常分配。帧时长按60ms估算播放总时长,并额外留3秒用于队列
* 排空、codec 尾部播放和调度抖动。
*/
@@ -47,8 +42,6 @@
static const char *TAG = "power_manager";
static const char *VOICE_TAG = "voice_prompt";
static bool s_power_hold_configured;
static bool s_voice_prompt_littlefs_mount_attempted;
static esp_err_t s_voice_prompt_littlefs_mount_result = ESP_ERR_INVALID_STATE;
/* GPIO 掩码集中构造,负数配置代表该板型不使用电源保持引脚。 */
static uint64_t power_manager_gpio_bit_mask(int gpio_num)
@@ -120,90 +113,25 @@ void platform_power_hold_init(void)
power_manager_set_keep_awake(true);
}
esp_err_t platform_mount_littlefs(void)
{
/* 记录首次挂载结果,使重复调用幂等且不会重复注册同一 VFS 路径。 */
if (s_voice_prompt_littlefs_mount_attempted) {
return s_voice_prompt_littlefs_mount_result;
}
esp_vfs_littlefs_conf_t conf = {
.base_path = VOICE_PROMPT_LITTLEFS_BASE_PATH,
.partition_label = VOICE_PROMPT_LITTLEFS_PARTITION,
.partition = NULL,
.format_if_mount_failed = false,
.dont_mount = false,
};
esp_err_t err = esp_vfs_littlefs_register(&conf);
if (err == ESP_ERR_INVALID_STATE &&
esp_littlefs_mounted(VOICE_PROMPT_LITTLEFS_PARTITION)) {
err = ESP_OK;
}
if (err == ESP_OK) {
size_t total_bytes = 0;
size_t used_bytes = 0;
esp_err_t info_err = esp_littlefs_info(VOICE_PROMPT_LITTLEFS_PARTITION,
&total_bytes,
&used_bytes);
if (info_err == ESP_OK) {
ESP_LOGI(VOICE_TAG,
"LittleFS mounted at %s, used=%u total=%u",
VOICE_PROMPT_LITTLEFS_BASE_PATH,
(unsigned)used_bytes,
(unsigned)total_bytes);
}
} else {
ESP_LOGW(VOICE_TAG,
"Mount LittleFS failed: %s",
esp_err_to_name(err));
}
s_voice_prompt_littlefs_mount_attempted = true;
s_voice_prompt_littlefs_mount_result = err;
return err;
}
esp_err_t voice_prompt_play_file(const char *path)
{
/* 文件采用“长度头 + Opus 数据”帧格式,逐帧送入统一的下行播放管线。 */
/* 素材位于应用镜像的只读区,逐帧送入统一的下行播放管线。 */
if (path == NULL || path[0] == '\0') {
return ESP_ERR_INVALID_ARG;
}
esp_err_t mount_err = platform_mount_littlefs();
if (mount_err != ESP_OK) {
return mount_err;
}
char resolved_path[160] = {0};
if (path[0] == '/') {
int written = snprintf(resolved_path, sizeof(resolved_path), "%s", path);
if (written < 0 || written >= (int)sizeof(resolved_path)) {
return ESP_ERR_INVALID_ARG;
}
} else {
int written = snprintf(resolved_path,
sizeof(resolved_path),
"%s/%s",
VOICE_PROMPT_DEFAULT_BASE_PATH,
path);
if (written < 0 || written >= (int)sizeof(resolved_path)) {
return ESP_ERR_INVALID_ARG;
}
}
FILE *file = fopen(resolved_path, "rb");
if (file == NULL) {
ESP_LOGW(VOICE_TAG, "Open prompt failed: %s", resolved_path);
return ESP_ERR_NOT_FOUND;
/* 查找结果是固件内的只读视图,播放期间无需挂载文件系统或管理文件句柄。 */
platform_asset_view_t asset = {0};
esp_err_t err = platform_asset_find(path, &asset);
if (err != ESP_OK) {
ESP_LOGW(VOICE_TAG, "Find prompt failed: %s (%s)", path, esp_err_to_name(err));
return err;
}
uint32_t playback_generation = 0U;
esp_err_t err = multimodal_audio_begin_downstream_response(&playback_generation);
err = multimodal_audio_begin_downstream_response(&playback_generation);
if (err != ESP_OK) {
ESP_LOGW(VOICE_TAG, "Begin prompt playback failed: %s", esp_err_to_name(err));
fclose(file);
return err;
}
@@ -213,25 +141,23 @@ esp_err_t voice_prompt_play_file(const char *path)
ESP_LOGW(VOICE_TAG,
"Allocate prompt opus packet buffer in PSRAM failed: %u bytes",
(unsigned)VOICE_PROMPT_MAX_OPUS_PACKET_BYTES);
fclose(file);
(void)multimodal_audio_finish_downstream_response();
return ESP_ERR_NO_MEM;
}
uint32_t packet_count = 0;
size_t total_bytes = 0;
size_t offset = 0;
while (true) {
uint8_t header[VOICE_PROMPT_FRAME_HEADER_BYTES] = {0};
size_t header_read = fread(header, 1, sizeof(header), file);
if (header_read == 0U) {
break;
}
if (header_read != sizeof(header)) {
ESP_LOGW(VOICE_TAG, "Truncated prompt frame header: %s", resolved_path);
/* 原 Opus 文件仍按 4 字节大端帧长逐帧存放;先校验边界,再拷入播放缓冲区。 */
while (offset < asset.size) {
if (asset.size - offset < VOICE_PROMPT_FRAME_HEADER_BYTES) {
ESP_LOGW(VOICE_TAG, "Truncated prompt frame header: %s", path);
err = ESP_ERR_INVALID_SIZE;
break;
}
const uint8_t *header = asset.data + offset;
offset += VOICE_PROMPT_FRAME_HEADER_BYTES;
uint32_t packet_len = ((uint32_t)header[0] << 24) |
((uint32_t)header[1] << 16) |
@@ -241,17 +167,18 @@ esp_err_t voice_prompt_play_file(const char *path)
ESP_LOGW(VOICE_TAG,
"Invalid prompt opus packet size: %u in %s",
(unsigned)packet_len,
resolved_path);
path);
err = ESP_ERR_INVALID_SIZE;
break;
}
size_t payload_read = fread(packet, 1, packet_len, file);
if (payload_read != packet_len) {
ESP_LOGW(VOICE_TAG, "Truncated prompt opus packet: %s", resolved_path);
if (packet_len > asset.size - offset) {
ESP_LOGW(VOICE_TAG, "Truncated prompt opus packet: %s", path);
err = ESP_ERR_INVALID_SIZE;
break;
}
memcpy(packet, asset.data + offset, packet_len);
offset += packet_len;
err = multimodal_audio_handle_downstream_opus(packet, packet_len);
if (err != ESP_OK) {
@@ -265,7 +192,6 @@ esp_err_t voice_prompt_play_file(const char *path)
}
free(packet);
fclose(file);
esp_err_t finish_err = multimodal_audio_finish_downstream_response();
if (err == ESP_OK) {
err = finish_err;
@@ -286,7 +212,7 @@ esp_err_t voice_prompt_play_file(const char *path)
if (err == ESP_OK) {
ESP_LOGI(VOICE_TAG,
"Prompt completed: %s packets=%u bytes=%u",
resolved_path,
path,
(unsigned)packet_count,
(unsigned)total_bytes);
}
+1 -12
View File
@@ -111,16 +111,6 @@ dependencies:
source:
type: idf
version: 5.5.4
joltwallet/littlefs:
component_hash: dcea25bcef2de023f089f5f01e8d8c46ad1b8ffef75861ad5ffb4098555839df
dependencies:
- name: idf
require: private
version: '>=5.0'
source:
registry_url: https://components.espressif.com/
type: service
version: 1.20.4
direct_dependencies:
- 78/esp-wifi-connect
- espressif/esp-sr
@@ -129,7 +119,6 @@ direct_dependencies:
- espressif/esp_websocket_client
- espressif/mdns
- idf
- joltwallet/littlefs
manifest_hash: c05cc39447d3758499f6a9a2b947c3fe21cf4fd47e0e9905cde8d79dc68d0472
manifest_hash: ba6b786f567d22dab6c465344a66b6b05775703588552240fe540a6e7e29bc2c
target: esp32s3
version: 2.0.0
+2 -2
View File
@@ -52,7 +52,7 @@ flowchart LR
LCD["ST7789 direct LCD backend"]
Audio["ES8311 audio pipeline"]
Network["Wi-Fi, BLE provisioning, HTTP, WebSocket"]
Storage["NVS, LittleFS"]
Storage["NVS, embedded assets, model partition"]
GPIO["GPIO, stepper, console UART"]
end
@@ -187,7 +187,7 @@ flowchart LR
PlatformReq["platform_lcd_request_presentation()"]
Pending["lcd_direct pending request"]
Render["cp_lcd_render loop"]
Draw["decode PNG asset frame\nfrom /littlefs/lcd"]
Draw["decode PNG asset frame\nfrom embedded app image"]
Panel["ST7789 panel"]
CP --> DomainReq --> Resolve --> Catalog --> PlatformReq --> Pending --> Render --> Draw --> Panel
@@ -273,7 +273,7 @@ key: last_shutdown
紧急确认次数:2次
确认期间采样间隔:1秒
实体按键唤醒 GPIO:-1(关闭)
关机提示音:/littlefs/audio_prompts/shutdown_power_empty.opus
关机提示音:audio_prompts/shutdown_power_empty.opus
充电唤醒 GPIO:GPIO10,低电平有效
```
@@ -326,7 +326,7 @@ key: last_shutdown
因为要求“播完才能关机”,提示文件缺失、损坏、Codec 初始化失败或播放通路持续故障时,软件会每秒重试而不会休眠。这符合当前产品决策,但可能导致电池最终依靠保护板断电。
量产前必须验证 LittleFS 中始终包含 `shutdown_power_empty.opus`,并验证关机场景下音频通路可初始化。
量产前必须验证应用镜像中包含 `audio_prompts/shutdown_power_empty.opus`,并验证关机场景下音频通路可初始化。
### 5.4 GPIO10 只能在实际充电时可靠唤醒
@@ -336,7 +336,7 @@ TP4057 状态脚低电平表示正在充电,高电平可能表示充满或未
### 5.5 Deep Sleep 唤醒是新的软件生命周期
唤醒后会重新执行 Bootloader 和 `app_main()`,运行内存、任务状态和普通全局变量不会保留。需要跨休眠保留的数据必须写入 NVS、LittleFS 或 RTC 内存。
唤醒后会重新执行 Bootloader 和 `app_main()`,运行内存、任务状态和普通全局变量不会保留。需要跨休眠保留的数据必须写入 NVS 或 RTC 内存;应用内嵌素材是只读的,不能保存运行时数据。
### 5.6 NVS 写入带来少量 Flash 磨损
+23
View File
@@ -0,0 +1,23 @@
# 应用内嵌素材与 Flash 布局
LCD PNG 和 Opus 提示音随应用固件一起发布。`ota_0`、`ota_1` 各为 4.5 MiB;每个槽位包含代码和同版本素材。语音识别模型仍单独使用 `model` 分区,NVS、OTA 数据和 coredump 维持各自用途。分区的实际偏移以 `16m.csv` 和每次构建生成的空间报告为准。
## 构建流程
1. `tools/optimize_lcd_assets.py check-current` 核对已提交的优化 PNG 与原图、manifest 一致。
2. `components/platform/CMakeLists.txt` 将明确列出的 PNG 和 `assets/audio_prompts/` 中的 Opus 复制到构建暂存目录。
3. `tools/pack_embedded_assets.py` 按名称排序,生成 `platform_assets.pack`。包内每项包含名称、长度及原始文件字节;程序通过名称查找,不需要 LittleFS 挂载。未列入 PNG 清单的原图不会进入固件。
4. 构建系统将整个包嵌入 `TQ_Cat_Code.bin`。OTA 对应用镜像执行原有长度、SHA-256 和镜像校验,素材随固件一起切换或回滚。
5. 每次 `idf.py build` 结束后,`tools/report_flash_usage.py` 输出完整分区地址、应用槽位占用、素材包大小、模型镜像占用和 Flash 未分配尾部;超出分区容量时构建失败。
`idf.py flash` 首次烧录 bootloader、分区表、OTA 数据、应用和模型。不再生成或烧录 `gifpack.bin`。旧分区布局的开发板需先整片擦除,再烧录新布局;只执行应用 OTA 不会改写分区表或模型分区。
## 运行时读取
`platform` 内部的 `platform_asset_find()` 返回只读地址和长度。调用方必须使用完整包内名称:LCD 使用 `lcd/<名称>.png`,提示音使用 `audio_prompts/<名称>.opus`。PNG 解码器直接读取应用镜像内的字节,不复制整张 PNG 到 PSRAM;解码后的 RGB565 缓存仍保留。Opus 按原有四字节大端长度头逐帧送入音频管线,并逐帧检查边界。
素材只随应用固件发布。仅修改某张图或提示音,也需要发布新的应用 OTA 镜像。`model` 分区仍通过 ESP-SR 独立加载,不属于应用 OTA。
## 验证边界
本地构建和素材包校验可证明文件被打包且镜像装入分区;LCD 显示、提示音播放、双槽 OTA、回滚及模型加载仍须在开发板上验证。
@@ -1,10 +1,10 @@
# Flash 与内存容量分析、风险及后续开发建议
> 历史容量快照:文中远程日志与 `logfs` 已从当前固件移除,分区地址和容量应以现行 `16m.csv` 与最新构建产物为准。
> 历史容量快照:文中远程日志、`logfs` 和独立 `gifpack` 分区已从当前固件移除。下文的“当前”“建议”和旧构建步骤仅针对当时版本,不能作为现行操作指南。现行分区以 `16m.csv` 与最新构建报告为准,资源操作见 [应用内嵌素材使用与添加指南](../应用内嵌素材使用与添加指南.md)。
本文记录当前固件的 Flash 分区使用情况、静态 RAM 使用情况、运行时内存风险、可优化内容和后续开发约束,用于功能评审、资源接入、OTA 设计和长期容量管理。
LittleFS 资源从源码到设备的详细流程见:
历史 LittleFS 资源流程归档见:
- [`docs/littlefs-resource-build-flash-mount.md`](littlefs-resource-build-flash-mount.md)
@@ -12,7 +12,7 @@
项目当前直接通过 ESP-IDF `esp_lcd` 驱动 ST7789P3,LCD 渲染链路主要支持:
- 从 LittleFS 读取 PNG;
- 从应用镜像内嵌素材包读取 PNG;
- 将 PNG 解码为 RGB565;
- 按 8 行一组复制到 LCD DMA 行缓冲;
- 合成充电、低电量等状态图标;
@@ -1,5 +1,7 @@
# LittleFS 资源构建、烧录与挂载流程
> 历史流程:当前固件已将 LCD 图片和提示音嵌入 OTA 应用镜像。下文的“当前”、命令和故障排查仅针对旧版 `gifpack` LittleFS,不适用于现行工程;请使用 [应用内嵌素材使用与添加指南](../应用内嵌素材使用与添加指南.md)。
本文说明项目中的 LCD 图片和提示音资源如何从源码目录进入设备 Flash,以及设备运行时如何通过 LittleFS 访问这些资源。同时给出新增、替换和排查资源问题的完整操作步骤。
## 1. 先建立整体认识
@@ -46,7 +46,7 @@ if (!s_server_success_prompt_played) {
if (should_play_prompt) {
(void)domain_runtime_behavior_play_prompt_file(
"/littlefs/audio_prompts/server_connect_success.opus");
"audio_prompts/server_connect_success.opus");
}
```
@@ -14,7 +14,7 @@
- Wi-Fi 连接、重连和配置热点;
- 设备身份、JWT 和会话令牌;
- 用户出生资料、每日运势和财富方向;
- NVS、LittleFS 和工厂 CLI。
- NVS、应用内嵌素材、模型分区和工厂 CLI。
## 2. 分层架构
@@ -238,12 +238,14 @@ TQ_Cat_Code-1.0.3.bin
### 6.4 该文件只是应用镜像,不是完整烧录包
`TQ_Cat_Code-1.0.2.bin` 只对应应用程序分区。它不包含以下独立镜像或资源:
`TQ_Cat_Code-1.0.2.bin` 只对应应用程序分区。它不包含以下独立镜像:
- Bootloader;
- 分区表;
- OTA data 初始化镜像;
- LittleFS/LCD/音频资源镜像。
- 独立模型分区镜像。
当前 LCD 图片和提示音已包含在应用镜像内,会随应用 OTA 一起更新。
该文件适合应用分区 OTA,不能被误认为包含全部分区的整机合并烧录镜像。工厂烧录仍需按照 ESP-IDF 生成的烧录参数写入各个分区。
+1 -1
View File
@@ -65,7 +65,7 @@ Transport disconnect/error events are not server error expressions.
## Asset Migration Direction
The image playback path is a platform-owned direct LCD asset pipeline. Runtime
PNG files are stored in the `gifpack` LittleFS partition, decoded through
PNG files are embedded in each OTA application image, decoded through
`lodepng`, converted to RGB565 in PSRAM, and written to the ST7789 panel through
the existing bounded row-transfer buffer.
+4 -4
View File
@@ -4,7 +4,7 @@
固件使用的 LCD 为 RGB565 显示格式,而设计工具导出的 PNG 通常是 RGB888 或 RGBA8888。原始 PNG 中包含 LCD 无法显示的颜色精度,部分图片还携带未实际使用的 Alpha 通道。
本项目在不改变 LCD 最终 RGB565 像素的前提下,将固件图片转换成 RGB565 颜色精度,并在颜色数量不超过 256 时保存为索引色 PNG,以降低 `gifpack` LittleFS 分区的占用。
本项目在不改变 LCD 最终 RGB565 像素的前提下,将固件图片转换成 RGB565 颜色精度,并在颜色数量不超过 256 时保存为索引色 PNG,以降低应用镜像中的素材占用。
正常固件编译直接使用已经提交到 Git 的优化图片,不要求开发环境安装 Pillow。只有修改或验证图片资源时才需要 Pillow。
@@ -16,9 +16,9 @@ components/platform/assets/
└── lcd_optimized/ 优化图片和 manifest.json,固件实际使用
```
两个目录中的 PNG 文件名必须一致。运行时路径仍为 `/lcd/<文件名>.png`,业务代码不需要区分原图和优化图。
两个目录中的 PNG 文件名必须一致。运行时逻辑名称为 `lcd/<文件名>.png`,业务代码不需要区分原图和优化图。
`lcd_source` 中的 `OTA.gif` 是历史资源,不属于当前 PNG 优化和 `gifpack` 打包清单。
`lcd_source` 中的 `OTA.gif` 是历史资源,不属于当前 PNG 优化和嵌入素材清单。
## 优化规则
@@ -81,7 +81,7 @@ components/platform/assets/lcd_optimized/manifest.json
- 优化图片没有缺失或被手动修改;
- 原图、优化图和清单中的文件集合一致。
资源过期时,构建会停止并提示重新运行 `generate`。资源有效时,构建系统把 `lcd_optimized` 中的 PNG 复制到构建目录并生成 `gifpack` 分区镜像。
资源过期时,构建会停止并提示重新运行 `generate`。资源有效时,构建系统把 `lcd_optimized` 中的 PNG 复制到构建目录,生成只读素材包并链接到应用镜像。
## 命令说明
@@ -1,5 +1,7 @@
# LCD PNG 存储优化实施报告
> 历史优化报告:本文记录当时的 `gifpack` LittleFS 布局及测试数据,不代表当前固件。现行素材流程见 [应用内嵌素材使用与添加指南](应用内嵌素材使用与添加指南.md)。
## 1. 基本信息
- 对应提交:`549f495 perf(assets): optimize LCD PNG storage`
@@ -74,7 +74,7 @@ CONFIG_TQ_LIGHT_STANDBY_IDLE_TIMEOUT_MS=300000
CONFIG_TQ_LIGHT_STANDBY_DIM_BACKLIGHT=y
CONFIG_TQ_LIGHT_STANDBY_BACKLIGHT_PERCENT=10
CONFIG_TQ_LIGHT_STANDBY_PROMPT_ENABLE=y
CONFIG_TQ_LIGHT_STANDBY_PROMPT_FILE="standby_5min.opus"
CONFIG_TQ_LIGHT_STANDBY_PROMPT_FILE="audio_prompts/standby_5min.opus"
CONFIG_TQ_LIGHT_STANDBY_DISABLE_SPEAKER_OUTPUT=y
CONFIG_TQ_LIGHT_STANDBY_RELEASE_MOTOR_HOLD=y
CONFIG_TQ_LIGHT_STANDBY_WIFI_POWER_SAVE=y
@@ -606,7 +606,7 @@ esp_err_t control_plane_light_standby_record_activity(
- ESP-IDF 5.5.4 完整构建通过。
- 当前构建生成的应用固件约 2.08 MiB,应用分区剩余约 41%。
- `standby_5min.opus` 已纳入 LittleFS 音频资源。
- `audio_prompts/standby_5min.opus` 已纳入应用镜像中的音频素材包。
- 源码差异格式检查通过。
- 已确认提示音等待的是播放管线终止事件,而不是固定时长硬等待。
@@ -321,13 +321,13 @@ Wi-Fi 断开表情在离线期间每 5 秒刷新一次,但被动断网提示
```c
if (!control_plane_is_sta_connected()) {
play_prompt("network_unstable.opus");
play_prompt("audio_prompts/network_unstable.opus");
request_immediate_wifi_reconnect();
return ESP_ERR_INVALID_STATE;
}
if (server_outage_confirmed) {
play_prompt("server_repair_wait_reopen.opus");
play_prompt("audio_prompts/server_repair_wait_reopen.opus");
schedule_fresh_auth_websocket_reconnect();
return ESP_ERR_INVALID_STATE;
}
+9 -7
View File
@@ -1,5 +1,7 @@
# 网络异常语音反馈与业务快速失败改造说明
> 本文中的提交、构建容量和测试结果是历史记录;资源路径已更新为现行应用内嵌素材名称。现行添加流程见 [应用内嵌素材使用与添加指南](应用内嵌素材使用与添加指南.md)。
## 1. 提交概况
```text
@@ -95,19 +97,19 @@ static portMUX_TYPE s_feedback_lock = portMUX_INITIALIZER_UNLOCKED;
### 3.2 复用现有音频资源
本次没有新增录音文件,直接复用 LittleFS 中已有的五个 Opus 资源:
本次没有新增录音文件,直接复用应用镜像中已有的五个 Opus 资源:
```c
#define CONTROL_PLANE_WIFI_INTERRUPTED_PROMPT_FILE \
"/littlefs/audio_prompts/network_interrupted_repairing.opus"
"audio_prompts/network_interrupted_repairing.opus"
#define CONTROL_PLANE_WIFI_ACTION_BLOCKED_PROMPT_FILE \
"/littlefs/audio_prompts/network_unstable.opus"
"audio_prompts/network_unstable.opus"
#define CONTROL_PLANE_SERVER_INTERRUPTED_PROMPT_FILE \
"/littlefs/audio_prompts/server_abnormal_interrupted.opus"
"audio_prompts/server_abnormal_interrupted.opus"
#define CONTROL_PLANE_SERVER_ACTION_BLOCKED_PROMPT_FILE \
"/littlefs/audio_prompts/server_repair_wait_reopen.opus"
"audio_prompts/server_repair_wait_reopen.opus"
#define CONTROL_PLANE_NETWORK_RESTORED_PROMPT_FILE \
"/littlefs/audio_prompts/signal_restored_business_resume.opus"
"audio_prompts/signal_restored_business_resume.opus"
```
对应关系如下:
@@ -486,7 +488,7 @@ Free space: 0x1682b0 bytes(40%)
- 新增网络反馈模块编译通过;
- 固件链接通过;
- 五个提示音均进入 LittleFS 镜像;
- 五个提示音均进入应用镜像中的素材包;
- 应用分区容量检查通过;
- Markdown 格式和空白错误检查通过。
@@ -272,7 +272,7 @@ return domain_runtime_request_expression(&request);
- 财位、校准、抽签等已有模型指令的枚举值未改变。
- 表达式请求结构增加字段,但现有请求均使用指定字段初始化,新增字段默认置零。
- 原五行调用的 `wuxing_element_valid` 默认为 `false`,继续随机显示。
- 未新增 LCD 图片,不增加 LittleFS 资源占用。
- 未新增 LCD 图片,不增加应用镜像中的素材占用。
- 未新增任务、队列或锁,不改变任务生命周期和并发模型。
### 5.2 当前限制
@@ -0,0 +1,68 @@
# 应用内嵌素材使用与添加指南
## 当前机制
LCD PNG 和提示音 Opus 在构建时生成 `platform_assets.pack`,作为二进制数据链接进应用固件。`ota_0`、`ota_1` 都装入完整的代码和素材,因此应用 OTA 会同步更新两者。运行时按**包内完整相对名称**查找,例如 `lcd/ota1.png`、`audio_prompts/boot.opus`;这些名称不是 VFS 文件路径,不能用 `fopen()` 读取,也不需要挂载 LittleFS。模型仍在独立的 `model` 分区,不随应用 OTA 更新。
源码、打包器和运行时入口:
| 用途 | 位置 |
| --- | --- |
| 原始 LCD PNG | `components/platform/assets/lcd_source/` |
| 优化后实际入包的 PNG | `components/platform/assets/lcd_optimized/` |
| 实际入包的 Opus | `components/platform/assets/audio_prompts/` |
| PNG 入包清单与构建暂存 | `components/platform/CMakeLists.txt` |
| 素材包格式 | `tools/pack_embedded_assets.py` |
| 只读查找实现 | `components/platform/src/platform_asset_bundle.c` |
| Flash 空间报告 | `tools/report_flash_usage.py` |
素材查找器属于 `platform` 内部接口。业务代码不要读取包内字节或拼接 Flash 地址,应调用下述 `domain`/`control_plane` 接口。
## 添加或替换 LCD 图片
1. 将原始 PNG 放到 `components/platform/assets/lcd_source/<名称>.png`。替换图片时只修改原图,不直接编辑 `lcd_optimized/`。
2. 按 [LCD 图片资源优化与维护](lcd-image-asset-optimization.zh-CN.md) 准备 Pillow 环境,在仓库根目录执行:
```bash
python tools/optimize_lcd_assets.py generate
python tools/optimize_lcd_assets.py verify
```
3. 在 `components/platform/CMakeLists.txt` 的 `platform_lcd_png_assets` 中加入 `<名称>`,不带 `.png`。未列入清单的 PNG 不会打包。
4. 在 `components/platform/src/lcd_direct_assets.c` 登记图片及其帧索引,并在 `lcd_direct_asset_frame()` 中登记对应的资源组名。**仅入包不会让 LCD 自动播放它。**
5. 如需新增业务表情,在 `components/domain/include/domain.h` 增加 `domain_expression_id_t`,并在 `components/domain/src/runtime/domain_runtime_adapters.c` 的目录映射中配置帧顺序、时长、重复次数、优先级和完成后行为。现有表情只换图时,通常不需要新增表情 ID。
业务触发点放在 `control_plane`,通过 `domain` 的语义接口请求,示例:
```c
domain_expression_request_t request = {
.id = DOMAIN_EXPRESSION_OTA_UPDATE,
.trigger = DOMAIN_EXPRESSION_TRIGGER_OTA_UPDATE,
};
esp_err_t err = domain_runtime_request_expression(&request);
```
`app_composition` 只负责启动接线;若启动阶段需要触发表情,应调用 `control_plane` 的公开入口。不要从上层直接调用 `platform_lcd_*` 或 `platform_asset_find()`。例如现有开机流程调用 `control_plane_request_boot_expression()`。
## 添加或替换提示音
1. 将文件放到 `components/platform/assets/audio_prompts/<名称>.opus`。该目录中的 `.opus` 会自动入包,无须修改 PNG 清单。当前打包格式只接受 `audio_prompts/` 下的单层文件,勿另建子目录。
2. 文件必须符合项目现有提示音格式:每个 Opus 包前有 **4 字节大端包长**,单包长度为 1–2048 字节。普通 Ogg Opus 文件不能仅改名为 `.opus` 使用。
3. 在负责该业务时机的 `control_plane` 模块中使用完整名称:
```c
esp_err_t err = domain_runtime_behavior_play_prompt_file(
"audio_prompts/new_prompt.opus");
```
4. 如果提示音路径由 Kconfig 配置,配置值也必须是完整包内名称,例如 `audio_prompts/boot.opus`;禁用可选提示音时按对应配置的空值规则处理。旧的 `/littlefs/...` 路径和裸文件名不再被解析。
播放接口同步完成分帧、下行播放会话和等待,调用位置应符合该业务的阻塞预算。需要新的语义触发或重试策略时,在对应的 `control_plane` 模块实现,不在 `app_composition` 中加入业务策略。
## 构建、更新与验收
使用项目指定的 ESP-IDF v5.5.4 环境执行 `idf.py build`。构建会先检查原图、优化图和 manifest 的一致性,再打包所选 PNG 与全部提示音。输出包括 `build/esp-idf/platform/platform_assets.pack` 和 `build/TQ_Cat_Code.bin`,并打印应用槽位、素材包、模型及 Flash 尾部空间。**素材包大小已包含在应用镜像大小中,不要重复相加。**
检查新增素材时,先确认构建通过、空间报告未超出 OTA 槽位,再在设备上验证实际画面或声音。更新素材需要重新发布应用 OTA;对于当前分区布局,首次烧录需同时烧录新的分区表、应用与模型。仅改素材也要生成新的应用镜像。
若运行时报告素材未找到,按顺序检查:调用名称是否含 `lcd/` 或 `audio_prompts/` 前缀;PNG 是否在 CMake 清单中;PNG 是否已登记到平台帧表和领域表情映射;提示音是否为项目分帧格式;设备运行的是否为本次构建的应用版本。构建目录的暂存文件不是设备上的文件系统。
+6 -7
View File
@@ -227,7 +227,7 @@ menu "TQ Cat Configuration"
string "Boot sound file"
default ""
help
Optional boot sound file path for the voice prompt backend.
Optional embedded asset name, such as audio_prompts/boot.opus.
Leave empty to use the semantic connecting prompt fallback.
config TQ_NETWORK_TASK_CORE
@@ -410,10 +410,10 @@ menu "TQ Cat Configuration"
config TQ_LIGHT_STANDBY_PROMPT_FILE
string "Light-standby entry prompt file"
default "standby_5min.opus"
default "audio_prompts/standby_5min.opus"
depends on TQ_LIGHT_STANDBY_PROMPT_ENABLE
help
Prompt file in the LittleFS audio_prompts directory.
Full embedded asset name, such as audio_prompts/standby_5min.opus.
config TQ_LIGHT_STANDBY_DISABLE_SPEAKER_OUTPUT
bool "Disable speaker output in light standby"
@@ -1022,7 +1022,7 @@ menu "TQ Cat Configuration"
config TQ_BATTERY_SHUTDOWN_PROMPT_FILE
string "Critical-battery shutdown prompt file"
default "/littlefs/audio_prompts/shutdown_power_empty.opus"
default "audio_prompts/shutdown_power_empty.opus"
depends on TQ_BATTERY_CONTROLLED_SHUTDOWN_ENABLE
endmenu
@@ -1380,10 +1380,9 @@ menu "TQ Cat Configuration"
config TQ_LOCAL_WAKE_RESPONSE_PROMPT_FILE
string "Local wake response prompt file"
depends on TQ_LOCAL_WAKE_ENABLE
default "local_wake_response.opus"
default "audio_prompts/local_wake_response.opus"
help
Prompt filename relative to /littlefs/audio_prompts. Absolute
LittleFS paths are also supported.
Full embedded asset name, such as audio_prompts/local_wake_response.opus.
config TQ_LOCAL_WAKE_RETRY_MS
int "Local wake startup retry delay (ms)"
+4 -4
View File
@@ -37,7 +37,7 @@ CONFIG_FREERTOS_USE_TRACE_FACILITY=y
# CONFIG_TQ_TASK_MONITOR_ENABLE is not set
# CONFIG_TQ_BOOT_SEQUENCE_DRY_RUN is not set
CONFIG_TQ_BOOT_SOUND_TIMEOUT_MS=3000
CONFIG_TQ_BOOT_SOUND_FILE="boot.opus"
CONFIG_TQ_BOOT_SOUND_FILE="audio_prompts/boot.opus"
CONFIG_TQ_NETWORK_TASK_CORE=0
CONFIG_TQ_LOCAL_TASK_CORE=1
CONFIG_TQ_LCD_RENDER_TASK_STACK_BYTES=8192
@@ -59,7 +59,7 @@ CONFIG_TQ_LIGHT_STANDBY_IDLE_TIMEOUT_MS=300000
CONFIG_TQ_LIGHT_STANDBY_DIM_BACKLIGHT=y
CONFIG_TQ_LIGHT_STANDBY_BACKLIGHT_PERCENT=10
CONFIG_TQ_LIGHT_STANDBY_PROMPT_ENABLE=y
CONFIG_TQ_LIGHT_STANDBY_PROMPT_FILE="standby_5min.opus"
CONFIG_TQ_LIGHT_STANDBY_PROMPT_FILE="audio_prompts/standby_5min.opus"
CONFIG_TQ_LIGHT_STANDBY_DISABLE_SPEAKER_OUTPUT=y
CONFIG_TQ_LIGHT_STANDBY_RELEASE_MOTOR_HOLD=y
CONFIG_TQ_LIGHT_STANDBY_WIFI_POWER_SAVE=y
@@ -106,7 +106,7 @@ CONFIG_TQ_OTA_MIN_LARGEST_DMA_BYTES=4096
CONFIG_TQ_OTA_MIN_BATTERY_PERCENT=20
CONFIG_TQ_OTA_POWER_RETRY_MS=60000
CONFIG_TQ_LOCAL_WAKE_RESPONSE_PROMPT_ENABLE=y
CONFIG_TQ_LOCAL_WAKE_RESPONSE_PROMPT_FILE="local_wake_response.opus"
CONFIG_TQ_LOCAL_WAKE_RESPONSE_PROMPT_FILE="audio_prompts/local_wake_response.opus"
CONFIG_TQ_LOCAL_WAKE_RETRY_MS=5000
CONFIG_TQ_LOCAL_WAKE_READY_TIMEOUT_MS=10000
CONFIG_TQ_LOCAL_WAKE_STOP_WAIT_MS=1000
@@ -338,5 +338,5 @@ CONFIG_TQ_BATTERY_SHUTDOWN_VOLTAGE_MV=3450
CONFIG_TQ_BATTERY_EMERGENCY_VOLTAGE_MV=3350
CONFIG_TQ_BATTERY_WAKE_RECOVERY_VOLTAGE_MV=3550
CONFIG_TQ_BATTERY_SHUTDOWN_WAKE_BUTTON_GPIO=-1
CONFIG_TQ_BATTERY_SHUTDOWN_PROMPT_FILE="/littlefs/audio_prompts/shutdown_power_empty.opus"
CONFIG_TQ_BATTERY_SHUTDOWN_PROMPT_FILE="audio_prompts/shutdown_power_empty.opus"
CONFIG_MULTIMODAL_AUDIO_PA_GPIO=11
+63
View File
@@ -0,0 +1,63 @@
#!/usr/bin/env python3
"""将素材确定性地打包为只读资源,随应用镜像一起参与 OTA。"""
from __future__ import annotations
import argparse
import struct
from pathlib import Path
MAGIC = b"TQASSET1"
# 与 platform_asset_bundle.c 的解析格式一致:小端包头及条目头,后接名称和原始字节。
HEADER = struct.Struct("<8sI")
ENTRY = struct.Struct("<HI")
ALLOWED = {"lcd": ".png", "audio_prompts": ".opus"}
def pack(source: Path) -> bytes:
# 排序固定条目顺序,保证相同输入生成相同的包;只接受当前运行时使用的两类素材。
files = sorted(path for path in source.rglob("*") if path.is_file())
if not files:
raise ValueError("asset staging directory is empty")
records: list[tuple[bytes, bytes]] = []
names: set[str] = set()
for path in files:
relative = path.relative_to(source)
if len(relative.parts) != 2 or relative.parts[0] not in ALLOWED:
raise ValueError(f"unexpected asset path: {relative}")
if path.suffix.lower() != ALLOWED[relative.parts[0]]:
raise ValueError(f"unexpected asset type: {relative}")
name = relative.as_posix()
if name in names:
raise ValueError(f"duplicate asset: {name}")
names.add(name)
encoded = name.encode("utf-8")
payload = path.read_bytes()
if not encoded or len(encoded) > 0xFFFF or not payload or len(payload) > 0xFFFFFFFF:
raise ValueError(f"invalid asset size: {name}")
records.append((encoded, payload))
output = bytearray(HEADER.pack(MAGIC, len(records)))
for name, payload in records:
output.extend(ENTRY.pack(len(name), len(payload)))
output.extend(name)
output.extend(payload)
print(f"Embedded assets: {len(records)} files, {sum(len(data) for _, data in records)} payload bytes, {len(output)} bundle bytes")
return bytes(output)
def main() -> None:
parser = argparse.ArgumentParser(description=__doc__)
parser.add_argument("source", type=Path)
parser.add_argument("output", type=Path)
args = parser.parse_args()
data = pack(args.source)
args.output.parent.mkdir(parents=True, exist_ok=True)
if not args.output.exists() or args.output.read_bytes() != data:
args.output.write_bytes(data)
if __name__ == "__main__":
main()
+71
View File
@@ -0,0 +1,71 @@
#!/usr/bin/env python3
"""每次构建后报告分区和镜像空间占用。"""
from __future__ import annotations
import argparse
import struct
from pathlib import Path
ENTRY = struct.Struct("<2sBBII16sI")
def partitions(path: Path) -> list[tuple[str, int, int]]:
# 读取本次构建生成的分区表二进制条目;报告不代表设备当前烧录状态。
data = path.read_bytes()
result = []
for offset in range(0, len(data) - ENTRY.size + 1, ENTRY.size):
magic, _type, _subtype, start, size, raw_name, _flags = ENTRY.unpack_from(data, offset)
if magic != b"\xaa\x50":
break
result.append((raw_name.split(b"\0", 1)[0].decode("ascii"), start, size))
if not result:
raise ValueError(f"no partitions found in {path}")
return result
def mib(size: int) -> str:
return f"{size / 1048576:.2f} MiB"
def main() -> None:
parser = argparse.ArgumentParser(description=__doc__)
parser.add_argument("--partition-table", required=True, type=Path)
parser.add_argument("--app", required=True, type=Path)
parser.add_argument("--asset-pack", required=True, type=Path)
parser.add_argument("--model", required=True, type=Path)
parser.add_argument("--flash-size", required=True)
args = parser.parse_args()
flash_size = int(args.flash_size.upper().removesuffix("MB")) * 1048576
entries = partitions(args.partition_table)
by_name = {name: (start, size) for name, start, size in entries}
# 素材包已计入应用镜像,单独显示仅用于分析,不能再加到应用占用中。
app_size = args.app.stat().st_size
asset_size = args.asset_pack.stat().st_size
model_size = args.model.stat().st_size
for name in ("ota_0", "ota_1", "model"):
if name not in by_name:
raise ValueError(f"missing required partition: {name}")
if app_size > min(by_name[name][1] for name in ("ota_0", "ota_1")):
raise ValueError("application image exceeds an OTA slot")
if model_size > by_name["model"][1]:
raise ValueError("model image exceeds model partition")
last_end = max(start + size for _, start, size in entries)
if last_end > flash_size:
raise ValueError("partition table exceeds configured Flash size")
print("\nFlash usage (local build images; device contents may differ):")
print(f" Flash capacity: {mib(flash_size)}")
for name, start, size in entries:
print(f" {name:9s} 0x{start:06x}-0x{start + size:06x} {mib(size)}")
slot_size = min(by_name[name][1] for name in ("ota_0", "ota_1"))
print(f" App image: {app_size:,} B / {slot_size:,} B ({app_size / slot_size:.1%}); free {mib(slot_size - app_size)} per OTA slot")
print(f" Embedded assets: {asset_size:,} B (included in app image)")
print(f" Model image: {model_size:,} B / {by_name['model'][1]:,} B ({model_size / by_name['model'][1]:.1%})")
print(f" Unpartitioned tail: {mib(flash_size - last_end)}\n")
if __name__ == "__main__":
main()