feat(assets): embed LCD and audio resources in OTA app
This commit is contained in:
@@ -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,
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user