From 7bc9a3dc764d8dbd7878b6a5d2ecb44f37df3654 Mon Sep 17 00:00:00 2001 From: chenjintao Date: Thu, 8 Oct 2026 16:58:33 +0800 Subject: [PATCH] feat(assets): embed LCD and audio resources in OTA app --- 16m.csv | 6 +- CMakeLists.txt | 13 ++ .../control_plane_multimodal_local_wake.c | 2 +- .../control_plane_multimodal_runtime.c | 2 +- .../runtime/control_plane_battery_service.c | 6 +- .../control_plane_connectivity_runtime.c | 2 +- .../src/runtime/control_plane_light_standby.c | 2 +- .../runtime/control_plane_network_feedback.c | 10 +- .../src/runtime/control_plane_system_events.c | 2 +- components/domain/include/domain.h | 1 - .../src/runtime/domain_runtime_adapters.c | 5 - components/platform/CMakeLists.txt | 20 ++- components/platform/idf_component.yml | 1 - components/platform/include/platform.h | 1 - .../platform/internal/lcd_direct_assets.h | 2 +- .../platform/internal/platform_asset_bundle.h | 14 ++ components/platform/internal/voice_prompt.h | 4 +- components/platform/src/lcd_direct.c | 70 ++-------- components/platform/src/lcd_direct_assets.c | 2 +- .../platform/src/platform_asset_bundle.c | 76 +++++++++++ .../platform/src/platform_behavior_stubs.c | 120 ++++-------------- dependencies.lock | 13 +- docs/architecture-and-flow-mermaid.md | 4 +- docs/controlled-low-battery-shutdown.zh-CN.md | 6 +- docs/embedded-assets-and-flash-layout.md | 23 ++++ .../flash-and-memory-capacity-analysis.md | 4 +- .../lcd-wall-clock-display.zh-CN.md | 2 +- .../littlefs-resource-build-flash-mount.md | 2 + ...ta-and-multimodal-stability-fixes.zh-CN.md | 2 +- .../project-runtime-workflow.md | 2 +- .../versioned-firmware-artifact.zh-CN.md | 6 +- docs/lcd-expressions.md | 2 +- docs/lcd-image-asset-optimization.zh-CN.md | 8 +- ...d-png-storage-optimization-report.zh-CN.md | 2 + .../light-standby-power-optimization.zh-CN.md | 4 +- docs/multi-wifi-credential-retention.zh-CN.md | 4 +- docs/network-outage-voice-feedback.zh-CN.md | 16 ++- ...ther-lucky-color-model-command-ui.zh-CN.md | 2 +- docs/应用内嵌素材使用与添加指南.md | 68 ++++++++++ main/Kconfig.projbuild | 13 +- sdkconfig.defaults | 8 +- tools/pack_embedded_assets.py | 63 +++++++++ tools/report_flash_usage.py | 71 +++++++++++ 43 files changed, 443 insertions(+), 243 deletions(-) create mode 100644 components/platform/internal/platform_asset_bundle.h create mode 100644 components/platform/src/platform_asset_bundle.c create mode 100644 docs/embedded-assets-and-flash-layout.md create mode 100644 docs/应用内嵌素材使用与添加指南.md create mode 100644 tools/pack_embedded_assets.py create mode 100644 tools/report_flash_usage.py diff --git a/16m.csv b/16m.csv index 92d8777..9a4446e 100644 --- a/16m.csv +++ b/16m.csv @@ -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, diff --git a/CMakeLists.txt b/CMakeLists.txt index 0ca1b6d..6640ab4 100644 --- a/CMakeLists.txt +++ b/CMakeLists.txt @@ -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 +) diff --git a/components/control_plane/src/multimodal/control_plane_multimodal_local_wake.c b/components/control_plane/src/multimodal/control_plane_multimodal_local_wake.c index 35471fd..362bf22 100644 --- a/components/control_plane/src/multimodal/control_plane_multimodal_local_wake.c +++ b/components/control_plane/src/multimodal/control_plane_multimodal_local_wake.c @@ -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 diff --git a/components/control_plane/src/multimodal/control_plane_multimodal_runtime.c b/components/control_plane/src/multimodal/control_plane_multimodal_runtime.c index 2f1f864..1c0dae8 100644 --- a/components/control_plane/src/multimodal/control_plane_multimodal_runtime.c +++ b/components/control_plane/src/multimodal/control_plane_multimodal_runtime.c @@ -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 diff --git a/components/control_plane/src/runtime/control_plane_battery_service.c b/components/control_plane/src/runtime/control_plane_battery_service.c index 200cdda..f0ad6ce 100644 --- a/components/control_plane/src/runtime/control_plane_battery_service.c +++ b/components/control_plane/src/runtime/control_plane_battery_service.c @@ -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 diff --git a/components/control_plane/src/runtime/control_plane_connectivity_runtime.c b/components/control_plane/src/runtime/control_plane_connectivity_runtime.c index 537aa2d..bdddf6b 100644 --- a/components/control_plane/src/runtime/control_plane_connectivity_runtime.c +++ b/components/control_plane/src/runtime/control_plane_connectivity_runtime.c @@ -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 diff --git a/components/control_plane/src/runtime/control_plane_light_standby.c b/components/control_plane/src/runtime/control_plane_light_standby.c index e0888f2..2121c09 100644 --- a/components/control_plane/src/runtime/control_plane_light_standby.c +++ b/components/control_plane/src/runtime/control_plane_light_standby.c @@ -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 diff --git a/components/control_plane/src/runtime/control_plane_network_feedback.c b/components/control_plane/src/runtime/control_plane_network_feedback.c index dc4e6c2..5629de0 100644 --- a/components/control_plane/src/runtime/control_plane_network_feedback.c +++ b/components/control_plane/src/runtime/control_plane_network_feedback.c @@ -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, diff --git a/components/control_plane/src/runtime/control_plane_system_events.c b/components/control_plane/src/runtime/control_plane_system_events.c index d3851f9..d093361 100644 --- a/components/control_plane/src/runtime/control_plane_system_events.c +++ b/components/control_plane/src/runtime/control_plane_system_events.c @@ -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 diff --git a/components/domain/include/domain.h b/components/domain/include/domain.h index 1decb13..8b436d0 100644 --- a/components/domain/include/domain.h +++ b/components/domain/include/domain.h @@ -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, diff --git a/components/domain/src/runtime/domain_runtime_adapters.c b/components/domain/src/runtime/domain_runtime_adapters.c index 065c740..477f460 100644 --- a/components/domain/src/runtime/domain_runtime_adapters.c +++ b/components/domain/src/runtime/domain_runtime_adapters.c @@ -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, diff --git a/components/platform/CMakeLists.txt b/components/platform/CMakeLists.txt index 3479036..dc7ff20 100644 --- a/components/platform/CMakeLists.txt +++ b/components/platform/CMakeLists.txt @@ -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) diff --git a/components/platform/idf_component.yml b/components/platform/idf_component.yml index 69507a2..636bdf5 100644 --- a/components/platform/idf_component.yml +++ b/components/platform/idf_component.yml @@ -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 diff --git a/components/platform/include/platform.h b/components/platform/include/platform.h index cb8510d..f057a4e 100644 --- a/components/platform/include/platform.h +++ b/components/platform/include/platform.h @@ -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, diff --git a/components/platform/internal/lcd_direct_assets.h b/components/platform/internal/lcd_direct_assets.h index 2537cef..33bb52f 100644 --- a/components/platform/internal/lcd_direct_assets.h +++ b/components/platform/internal/lcd_direct_assets.h @@ -7,7 +7,7 @@ extern "C" { #endif -/* RGB565 图片描述符,实际像素由渲染任务根据路径加载和解码。 */ +/* RGB565 图片描述符,实际像素由渲染任务根据逻辑名称查找和解码。 */ typedef struct { const char *path; } lcd_direct_image_t; diff --git a/components/platform/internal/platform_asset_bundle.h b/components/platform/internal/platform_asset_bundle.h new file mode 100644 index 0000000..4736e7a --- /dev/null +++ b/components/platform/internal/platform_asset_bundle.h @@ -0,0 +1,14 @@ +#pragma once + +#include +#include + +#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); diff --git a/components/platform/internal/voice_prompt.h b/components/platform/internal/voice_prompt.h index 181b12c..105aa7f 100644 --- a/components/platform/internal/voice_prompt.h +++ b/components/platform/internal/voice_prompt.h @@ -7,8 +7,8 @@ extern "C" { #endif /* - * 同步播放 LittleFS 中的 Opus 提示音。函数负责分帧、下行播放会话和完成等待, - * 返回时文件句柄已关闭;路径解析和播放超时均在实现内处理。 + * 同步播放应用镜像中的 Opus 提示音。函数负责分帧、下行播放会话和完成等待; + * 素材查找和播放超时均在实现内处理。 */ esp_err_t voice_prompt_play_file(const char *path); diff --git a/components/platform/src/lcd_direct.c b/components/platform/src/lcd_direct.c index ad607dc..62459dd 100644 --- a/components/platform/src/lcd_direct.c +++ b/components/platform/src/lcd_direct.c @@ -7,15 +7,13 @@ #include "lcd_direct.h" #include "lcd_direct_assets.h" +#include "platform_asset_bundle.h" -#include -#include #include #include #include #include #include -#include #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, diff --git a/components/platform/src/lcd_direct_assets.c b/components/platform/src/lcd_direct_assets.c index efd0607..943ac61 100644 --- a/components/platform/src/lcd_direct_assets.c +++ b/components/platform/src/lcd_direct_assets.c @@ -2,7 +2,7 @@ #include -#define LCD_ASSET_BASE_PATH "/littlefs/lcd/" +#define LCD_ASSET_BASE_PATH "lcd/" /* * 静态表只保存资源元数据和路径,不在此文件执行 PNG 解码。 diff --git a/components/platform/src/platform_asset_bundle.c b/components/platform/src/platform_asset_bundle.c new file mode 100644 index 0000000..b1af698 --- /dev/null +++ b/components/platform/src/platform_asset_bundle.c @@ -0,0 +1,76 @@ +#include "platform_asset_bundle.h" + +#include +#include + +#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; +} diff --git a/components/platform/src/platform_behavior_stubs.c b/components/platform/src/platform_behavior_stubs.c index f2b0ac1..73001f4 100644 --- a/components/platform/src/platform_behavior_stubs.c +++ b/components/platform/src/platform_behavior_stubs.c @@ -4,7 +4,6 @@ * SPDX-License-Identifier: Apache-2.0 */ -#include #include #include #include @@ -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); } diff --git a/dependencies.lock b/dependencies.lock index b7f689a..53146e9 100644 --- a/dependencies.lock +++ b/dependencies.lock @@ -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 diff --git a/docs/architecture-and-flow-mermaid.md b/docs/architecture-and-flow-mermaid.md index 28ce7da..e90fd27 100644 --- a/docs/architecture-and-flow-mermaid.md +++ b/docs/architecture-and-flow-mermaid.md @@ -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 diff --git a/docs/controlled-low-battery-shutdown.zh-CN.md b/docs/controlled-low-battery-shutdown.zh-CN.md index 083d06c..7f6d404 100644 --- a/docs/controlled-low-battery-shutdown.zh-CN.md +++ b/docs/controlled-low-battery-shutdown.zh-CN.md @@ -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 磨损 diff --git a/docs/embedded-assets-and-flash-layout.md b/docs/embedded-assets-and-flash-layout.md new file mode 100644 index 0000000..cc9097b --- /dev/null +++ b/docs/embedded-assets-and-flash-layout.md @@ -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、回滚及模型加载仍须在开发板上验证。 diff --git a/docs/knowledge vault/flash-and-memory-capacity-analysis.md b/docs/knowledge vault/flash-and-memory-capacity-analysis.md index ad64441..b3eccee 100644 --- a/docs/knowledge vault/flash-and-memory-capacity-analysis.md +++ b/docs/knowledge vault/flash-and-memory-capacity-analysis.md @@ -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) diff --git a/docs/knowledge vault/lcd-wall-clock-display.zh-CN.md b/docs/knowledge vault/lcd-wall-clock-display.zh-CN.md index bc6ac1d..a12aad2 100644 --- a/docs/knowledge vault/lcd-wall-clock-display.zh-CN.md +++ b/docs/knowledge vault/lcd-wall-clock-display.zh-CN.md @@ -12,7 +12,7 @@ 项目当前直接通过 ESP-IDF `esp_lcd` 驱动 ST7789P3,LCD 渲染链路主要支持: -- 从 LittleFS 读取 PNG; +- 从应用镜像内嵌素材包读取 PNG; - 将 PNG 解码为 RGB565; - 按 8 行一组复制到 LCD DMA 行缓冲; - 合成充电、低电量等状态图标; diff --git a/docs/knowledge vault/littlefs-resource-build-flash-mount.md b/docs/knowledge vault/littlefs-resource-build-flash-mount.md index bba64f7..56ac51b 100644 --- a/docs/knowledge vault/littlefs-resource-build-flash-mount.md +++ b/docs/knowledge vault/littlefs-resource-build-flash-mount.md @@ -1,5 +1,7 @@ # LittleFS 资源构建、烧录与挂载流程 +> 历史流程:当前固件已将 LCD 图片和提示音嵌入 OTA 应用镜像。下文的“当前”、命令和故障排查仅针对旧版 `gifpack` LittleFS,不适用于现行工程;请使用 [应用内嵌素材使用与添加指南](../应用内嵌素材使用与添加指南.md)。 + 本文说明项目中的 LCD 图片和提示音资源如何从源码目录进入设备 Flash,以及设备运行时如何通过 LittleFS 访问这些资源。同时给出新增、替换和排查资源问题的完整操作步骤。 ## 1. 先建立整体认识 diff --git a/docs/knowledge vault/ota-and-multimodal-stability-fixes.zh-CN.md b/docs/knowledge vault/ota-and-multimodal-stability-fixes.zh-CN.md index 811eae3..90c7c82 100644 --- a/docs/knowledge vault/ota-and-multimodal-stability-fixes.zh-CN.md +++ b/docs/knowledge vault/ota-and-multimodal-stability-fixes.zh-CN.md @@ -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"); } ``` diff --git a/docs/knowledge vault/project-runtime-workflow.md b/docs/knowledge vault/project-runtime-workflow.md index 35f881d..862be83 100644 --- a/docs/knowledge vault/project-runtime-workflow.md +++ b/docs/knowledge vault/project-runtime-workflow.md @@ -14,7 +14,7 @@ - Wi-Fi 连接、重连和配置热点; - 设备身份、JWT 和会话令牌; - 用户出生资料、每日运势和财富方向; -- NVS、LittleFS 和工厂 CLI。 +- NVS、应用内嵌素材、模型分区和工厂 CLI。 ## 2. 分层架构 diff --git a/docs/knowledge vault/versioned-firmware-artifact.zh-CN.md b/docs/knowledge vault/versioned-firmware-artifact.zh-CN.md index 92527f6..94fe9d1 100644 --- a/docs/knowledge vault/versioned-firmware-artifact.zh-CN.md +++ b/docs/knowledge vault/versioned-firmware-artifact.zh-CN.md @@ -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 生成的烧录参数写入各个分区。 diff --git a/docs/lcd-expressions.md b/docs/lcd-expressions.md index 3d5d84c..8d0b9d6 100644 --- a/docs/lcd-expressions.md +++ b/docs/lcd-expressions.md @@ -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. diff --git a/docs/lcd-image-asset-optimization.zh-CN.md b/docs/lcd-image-asset-optimization.zh-CN.md index 7d2c9d4..bbe98a2 100644 --- a/docs/lcd-image-asset-optimization.zh-CN.md +++ b/docs/lcd-image-asset-optimization.zh-CN.md @@ -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 复制到构建目录,生成只读素材包并链接到应用镜像。 ## 命令说明 diff --git a/docs/lcd-png-storage-optimization-report.zh-CN.md b/docs/lcd-png-storage-optimization-report.zh-CN.md index b094993..d66d818 100644 --- a/docs/lcd-png-storage-optimization-report.zh-CN.md +++ b/docs/lcd-png-storage-optimization-report.zh-CN.md @@ -1,5 +1,7 @@ # LCD PNG 存储优化实施报告 +> 历史优化报告:本文记录当时的 `gifpack` LittleFS 布局及测试数据,不代表当前固件。现行素材流程见 [应用内嵌素材使用与添加指南](应用内嵌素材使用与添加指南.md)。 + ## 1. 基本信息 - 对应提交:`549f495 perf(assets): optimize LCD PNG storage` diff --git a/docs/light-standby-power-optimization.zh-CN.md b/docs/light-standby-power-optimization.zh-CN.md index 78825e8..c064da3 100644 --- a/docs/light-standby-power-optimization.zh-CN.md +++ b/docs/light-standby-power-optimization.zh-CN.md @@ -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` 已纳入应用镜像中的音频素材包。 - 源码差异格式检查通过。 - 已确认提示音等待的是播放管线终止事件,而不是固定时长硬等待。 diff --git a/docs/multi-wifi-credential-retention.zh-CN.md b/docs/multi-wifi-credential-retention.zh-CN.md index 6e782ca..14505c8 100644 --- a/docs/multi-wifi-credential-retention.zh-CN.md +++ b/docs/multi-wifi-credential-retention.zh-CN.md @@ -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; } diff --git a/docs/network-outage-voice-feedback.zh-CN.md b/docs/network-outage-voice-feedback.zh-CN.md index 27a6e31..8b1bd62 100644 --- a/docs/network-outage-voice-feedback.zh-CN.md +++ b/docs/network-outage-voice-feedback.zh-CN.md @@ -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 格式和空白错误检查通过。 diff --git a/docs/weather-lucky-color-model-command-ui.zh-CN.md b/docs/weather-lucky-color-model-command-ui.zh-CN.md index 707cd38..23dd65e 100644 --- a/docs/weather-lucky-color-model-command-ui.zh-CN.md +++ b/docs/weather-lucky-color-model-command-ui.zh-CN.md @@ -272,7 +272,7 @@ return domain_runtime_request_expression(&request); - 财位、校准、抽签等已有模型指令的枚举值未改变。 - 表达式请求结构增加字段,但现有请求均使用指定字段初始化,新增字段默认置零。 - 原五行调用的 `wuxing_element_valid` 默认为 `false`,继续随机显示。 -- 未新增 LCD 图片,不增加 LittleFS 资源占用。 +- 未新增 LCD 图片,不增加应用镜像中的素材占用。 - 未新增任务、队列或锁,不改变任务生命周期和并发模型。 ### 5.2 当前限制 diff --git a/docs/应用内嵌素材使用与添加指南.md b/docs/应用内嵌素材使用与添加指南.md new file mode 100644 index 0000000..78b497d --- /dev/null +++ b/docs/应用内嵌素材使用与添加指南.md @@ -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 是否已登记到平台帧表和领域表情映射;提示音是否为项目分帧格式;设备运行的是否为本次构建的应用版本。构建目录的暂存文件不是设备上的文件系统。 diff --git a/main/Kconfig.projbuild b/main/Kconfig.projbuild index b1aca84..9bf35dd 100644 --- a/main/Kconfig.projbuild +++ b/main/Kconfig.projbuild @@ -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)" diff --git a/sdkconfig.defaults b/sdkconfig.defaults index a2e734d..5ec8ccf 100644 --- a/sdkconfig.defaults +++ b/sdkconfig.defaults @@ -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 diff --git a/tools/pack_embedded_assets.py b/tools/pack_embedded_assets.py new file mode 100644 index 0000000..1528b0e --- /dev/null +++ b/tools/pack_embedded_assets.py @@ -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(" 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() diff --git a/tools/report_flash_usage.py b/tools/report_flash_usage.py new file mode 100644 index 0000000..795f9a6 --- /dev/null +++ b/tools/report_flash_usage.py @@ -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()