docs: 补充核心分层中文注释

This commit is contained in:
chenjintao
2026-09-29 18:18:44 +08:00
parent a77471a7b1
commit 9fd01bb61b
81 changed files with 1189 additions and 112 deletions
+46 -4
View File
@@ -1,8 +1,8 @@
/*
* Direct ST7789P3 LCD backend.
* ST7789P3 LCD 直接驱动后端。
*
* This backend owns the low-level panel initialization, a serialized render
* loop, and a minimal presentation queue.
* 本模块独占面板底层初始化和 SPI 访问,并通过串行渲染循环处理最小化的
* presentation 队列;调用方不得绕过该任务直接操作面板。
*/
#include "lcd_direct.h"
@@ -44,14 +44,23 @@
#define LCD_DIRECT_WIDTH LCD_DIRECT_NATIVE_WIDTH
#define LCD_DIRECT_HEIGHT LCD_DIRECT_NATIVE_HEIGHT
#endif
/*
* 每次只合成并传输8行,用较小 DMA 缓冲换取更多 SPI 提交次数;100ms 是单批传输
* 故障边界。预热允许5秒是因为首次 PNG 文件读取和解码远慢于缓存命中。
*/
#define LCD_DIRECT_ROWS_PER_TRANSFER 8U
#define LCD_DIRECT_PIXELS_PER_TRANSFER (LCD_DIRECT_WIDTH * LCD_DIRECT_ROWS_PER_TRANSFER)
#define LCD_DIRECT_PRESENTATION_WAIT_POLL_MS 20U
#define LCD_DIRECT_TRANSFER_TIMEOUT_MS 100U
#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
#define LCD_DIRECT_PATH_MAX 96U
/* 状态图标坐标基于逻辑屏幕尺寸计算,旋转屏幕时无需复制另一套绝对坐标。 */
#define LCD_DIRECT_CHARGING_ICON_WIDTH 50U
#define LCD_DIRECT_CHARGING_ICON_X (LCD_DIRECT_WIDTH - LCD_DIRECT_CHARGING_ICON_WIDTH - 8U)
#define LCD_DIRECT_CHARGING_ICON_Y 8U
@@ -59,15 +68,21 @@
#define LCD_DIRECT_LOW_BATTERY_ICON_HEIGHT 36U
#define LCD_DIRECT_LOW_BATTERY_ICON_X ((LCD_DIRECT_WIDTH - LCD_DIRECT_LOW_BATTERY_ICON_WIDTH) / 2U)
#define LCD_DIRECT_LOW_BATTERY_ICON_Y (LCD_DIRECT_HEIGHT - LCD_DIRECT_LOW_BATTERY_ICON_HEIGHT - 8U)
/* 基础表情按100ms节奏播放,有限动画末帧额外停留200ms以避免视觉跳变过快。 */
#define LCD_DIRECT_LAST_FRAME_EXTRA_HOLD_MS 200U
#define LCD_DIRECT_BASIC_FACE_FRAME_HOLD_MS 100U
#define LCD_DIRECT_BASIC_FACE_LAST_FRAME_HOLD_MS 100U
/*
* 20kHz 背光 PWM 高于常见可听频率;10位占空比分辨率提供平滑亮度变化。
* 独立背光锁最多等待100ms,防止控制路径因并发更新无限阻塞。
*/
#define LCD_DIRECT_BACKLIGHT_PWM_FREQUENCY_HZ 20000U
#define LCD_DIRECT_BACKLIGHT_PWM_DUTY_RESOLUTION LEDC_TIMER_10_BIT
#define LCD_DIRECT_BACKLIGHT_PWM_DUTY_MAX ((1U << 10U) - 1U)
#define LCD_DIRECT_BACKLIGHT_PWM_TIMER LEDC_TIMER_0
#define LCD_DIRECT_BACKLIGHT_PWM_CHANNEL LEDC_CHANNEL_0
#define LCD_DIRECT_BACKLIGHT_LOCK_TIMEOUT_MS 100U
/* 5x7 点阵放大4倍,24像素步进为字形留出间隔;颜色为 RGB565 白字黑描边。 */
#define LCD_DIRECT_CLOCK_X 12U
#define LCD_DIRECT_CLOCK_Y 10U
#define LCD_DIRECT_CLOCK_GLYPH_WIDTH 5U
@@ -79,6 +94,7 @@
#define LCD_DIRECT_CLOCK_COLOR 0xFFFFU
#define LCD_DIRECT_CLOCK_OUTLINE_COLOR 0x0000U
/* ST7789P3 的方向控制(MADCTL)和 RAM 数据格式控制(RAMCTRL)命令号。 */
#define LCD_CMD_MADCTL 0x36
#define LCD_CMD_RAMCTRL 0xB0
@@ -92,6 +108,7 @@ unsigned lodepng_esp_decode32(unsigned char **out,
const char *lodepng_esp_error_text(unsigned code);
typedef struct {
/* 请求中的外部指针在入队前复制到此结构,调用返回后不再依赖调用方内存。 */
platform_lcd_presentation_kind_t kind;
uint8_t priority;
bool interrupt_current;
@@ -107,6 +124,7 @@ typedef struct {
} lcd_direct_request_t;
typedef struct lcd_direct_decoded_image_t {
/* 缓存项拥有 pixels/alpha;pinned 项不可被 LRU 淘汰。 */
bool valid;
bool alpha_valid;
char path[LCD_DIRECT_PATH_MAX];
@@ -119,6 +137,7 @@ typedef struct lcd_direct_decoded_image_t {
} lcd_direct_decoded_image_t;
typedef struct {
/* 图标优先引用缓存,缓存不足时 owns_transient 标记本次绘制临时所有权。 */
uint32_t completed_frames;
uint64_t total_draw_us;
uint32_t min_draw_us;
@@ -200,6 +219,7 @@ static uint16_t s_line_buffer[LCD_DIRECT_PIXELS_PER_TRANSFER];
static lcd_direct_decoded_image_t s_frame_cache[LCD_DIRECT_FRAME_CACHE_MAX];
static uint32_t s_cache_use_counter;
/* 渲染状态由 s_state_lock 保护,面板和缓存访问则由 s_lock 串行化。 */
static void lcd_direct_signal_completion(void);
static void lcd_direct_clock_text(char text[LCD_DIRECT_CLOCK_TEXT_LENGTH + 1U]);
static void lcd_direct_overlay_clock(uint16_t *pixel_buffer,
@@ -211,6 +231,7 @@ static bool IRAM_ATTR lcd_direct_on_color_trans_done(esp_lcd_panel_io_handle_t p
esp_lcd_panel_io_event_data_t *event_data,
void *user_ctx)
{
/* SPI 完成回调位于中断上下文,只释放信号量并按需触发任务切换。 */
(void)panel_io;
(void)event_data;
(void)user_ctx;
@@ -233,6 +254,7 @@ static uint16_t lcd_direct_swap_color(uint16_t color)
static esp_err_t lcd_direct_draw_rows(uint16_t row_start, uint16_t row_count)
{
/* 每批行发送后等待传输完成,确保静态行缓冲不会在 DMA 使用期间被覆盖。 */
ESP_RETURN_ON_FALSE(s_panel != NULL, ESP_ERR_INVALID_STATE, TAG, "panel is not ready");
ESP_RETURN_ON_FALSE(s_transfer_done_sem != NULL,
ESP_ERR_INVALID_STATE,
@@ -296,6 +318,7 @@ static uint16_t lcd_direct_blend_rgb565(uint16_t dst, uint16_t src, uint8_t alph
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,
@@ -356,6 +379,7 @@ static esp_err_t lcd_direct_decode_png(const char *path,
bool keep_alpha,
lcd_direct_decoded_image_t *out_decoded)
{
/* 解码后立即转换为面板原生 RGB565,仅在确有透明像素时保留 alpha 平面。 */
ESP_RETURN_ON_FALSE(path != NULL && out_decoded != NULL,
ESP_ERR_INVALID_ARG,
TAG,
@@ -443,6 +467,7 @@ static esp_err_t lcd_direct_decode_png(const char *path,
static lcd_direct_decoded_image_t *lcd_direct_find_cached_image(const char *path, bool require_alpha)
{
/* 命中时更新单调递增使用序号,供固定容量缓存执行 LRU 淘汰。 */
if (path == NULL) {
return NULL;
}
@@ -459,6 +484,7 @@ static lcd_direct_decoded_image_t *lcd_direct_find_cached_image(const char *path
static lcd_direct_decoded_image_t *lcd_direct_find_cache_slot(void)
{
/* 优先空槽,否则淘汰最久未使用且未 pinned 的资源。 */
lcd_direct_decoded_image_t *oldest = NULL;
for (size_t i = 0; i < LCD_DIRECT_FRAME_CACHE_MAX; ++i) {
if (!s_frame_cache[i].valid) {
@@ -481,6 +507,7 @@ static esp_err_t lcd_direct_get_png_image(const char *path,
lcd_direct_decoded_image_t **out_image,
lcd_direct_decoded_image_t *transient)
{
/* 缓存已满且全部固定时,允许调用方提供 transient 承接一次性解码结果。 */
ESP_RETURN_ON_FALSE(path != NULL && out_image != NULL,
ESP_ERR_INVALID_ARG,
TAG,
@@ -579,6 +606,7 @@ static esp_err_t lcd_direct_gpio_init(void)
static esp_err_t lcd_direct_panel_init(void)
{
/* 面板命令、方向和颜色格式在显示开启前一次性配置完成。 */
spi_bus_config_t bus_config = {
.mosi_io_num = CONFIG_TQ_SCREEN_SPI_MOSI_GPIO,
.miso_io_num = CONFIG_TQ_SCREEN_SPI_MISO_GPIO,
@@ -735,6 +763,7 @@ static size_t lcd_direct_prepare_icons(const lcd_direct_icon_draw_t *icons,
lcd_direct_prepared_icon_t *prepared,
size_t prepared_capacity)
{
/* 绘制前集中解析全部图标,避免逐行合成时反复访问文件系统。 */
if (icons == NULL || icon_count == 0U || prepared == NULL || prepared_capacity == 0U) {
return 0;
}
@@ -836,6 +865,7 @@ static void lcd_direct_overlay_icons(uint16_t *pixel_buffer,
const lcd_direct_prepared_icon_t *icons,
size_t icon_count)
{
/* 只处理当前传输行范围,并依据 alpha 平面与背景执行 RGB565 混合。 */
if (pixel_buffer == NULL || icons == NULL || icon_count == 0U) {
return;
}
@@ -972,6 +1002,7 @@ static void lcd_direct_overlay_clock(uint16_t *pixel_buffer,
uint16_t row_count,
const char text[LCD_DIRECT_CLOCK_TEXT_LENGTH + 1U])
{
/* 时钟作为最终覆盖层绘制,保证 presentation 与状态图标不会遮住时间。 */
if (pixel_buffer == NULL || text == NULL) {
return;
}
@@ -996,6 +1027,7 @@ static esp_err_t lcd_direct_draw_png_image(const lcd_direct_image_t *image,
const lcd_direct_icon_draw_t *icons,
size_t icon_count)
{
/* 按固定行块合成主图、状态图标和时钟,限制单次 DMA 缓冲占用。 */
ESP_RETURN_ON_FALSE(image != NULL,
ESP_ERR_INVALID_ARG,
TAG,
@@ -1138,6 +1170,7 @@ static bool lcd_direct_preheat_is_pending(void)
static bool lcd_direct_take_preheat(lcd_direct_request_t *out_request,
uint32_t *out_generation)
{
/* 取出请求与标记 in-progress 在同一临界区完成,确保只有渲染任务消费。 */
bool has_request = false;
portENTER_CRITICAL(&s_state_lock);
if (s_preheat_pending_valid) {
@@ -1165,7 +1198,7 @@ static void lcd_direct_complete_preheat(uint32_t generation, esp_err_t result)
}
}
/* Called only by the LCD render task while it owns s_lock. */
/* 仅由持有 s_lock 的 LCD 渲染任务调用,预热不会与实际绘制并发访问缓存。 */
static bool lcd_direct_service_preheat(void)
{
lcd_direct_request_t request = {0};
@@ -1465,6 +1498,7 @@ static esp_err_t lcd_direct_run_boundary_test(const lcd_direct_request_t *reques
static esp_err_t lcd_direct_play_asset_sequence(const lcd_direct_request_t *request)
{
/* 每帧间隙检查更高优先级请求和停止标志,使长动画可以被及时抢占。 */
ESP_RETURN_ON_FALSE(request != NULL,
ESP_ERR_INVALID_ARG,
TAG,
@@ -1602,6 +1636,7 @@ static esp_err_t lcd_direct_play_basic_face_until_pending(void)
static void lcd_direct_signal_completion(void)
{
/* 完成计数是事实来源,二值信号量仅用于唤醒等待者,不承担事件计数。 */
portENTER_CRITICAL(&s_state_lock);
s_completion_count++;
portEXIT_CRITICAL(&s_state_lock);
@@ -1612,6 +1647,7 @@ static void lcd_direct_signal_completion(void)
static bool lcd_direct_take_pending(lcd_direct_request_t *out)
{
/* pending 槽采用移动语义,取出后立即清零,渲染任务独占本地副本。 */
bool has_pending = false;
portENTER_CRITICAL(&s_state_lock);
if (s_pending_valid) {
@@ -1626,6 +1662,7 @@ static bool lcd_direct_take_pending(lcd_direct_request_t *out)
esp_err_t lcd_direct_init_device(void)
{
/* 同步原语先于 GPIO/SPI 创建,任意异步完成回调到达时所需对象均已存在。 */
if (s_initialized) {
return ESP_OK;
}
@@ -1713,6 +1750,7 @@ esp_err_t lcd_direct_set_backlight_brightness(uint8_t brightness_percent)
void lcd_direct_start_render_loop(void)
{
/* 当前调用任务本身成为渲染任务,因此此函数在收到停止请求前不会返回。 */
if (!s_initialized) {
ESP_LOGW(TAG, "Direct LCD is not initialized, skip render loop");
return;
@@ -1810,6 +1848,7 @@ void lcd_direct_start_render_loop(void)
esp_err_t lcd_direct_stop_render_loop(int timeout_ms)
{
/* 先协作停止;超过边界后删除已记录任务,并取消所有预热等待者。 */
if (timeout_ms < 0) {
return ESP_ERR_INVALID_ARG;
}
@@ -1887,6 +1926,7 @@ esp_err_t lcd_direct_set_status_overlay(const platform_lcd_status_overlay_t *ove
esp_err_t lcd_direct_request_presentation(const platform_lcd_presentation_request_t *request)
{
/* 校验并深拷贝可变数组后再发布 pending,发布完成即不再引用调用方数据。 */
if (!s_initialized || request == NULL) {
return ESP_ERR_INVALID_STATE;
}
@@ -1934,6 +1974,7 @@ esp_err_t lcd_direct_request_presentation(const platform_lcd_presentation_reques
esp_err_t lcd_direct_preheat_presentation(const platform_lcd_presentation_request_t *request)
{
/* 每次只允许一个同步预热等待者,以 generation 区分迟到的完成信号。 */
if (!s_initialized || request == NULL) {
return ESP_ERR_INVALID_STATE;
}
@@ -2022,6 +2063,7 @@ uint32_t lcd_direct_presentation_completion_count(void)
esp_err_t lcd_direct_wait_presentation_completion(uint32_t since_count, uint32_t timeout_ms)
{
/* 循环检查单调计数而非相信信号量次数,可正确处理合并唤醒和先完成后等待。 */
if (!s_initialized) {
return ESP_ERR_INVALID_STATE;
}
@@ -4,6 +4,10 @@
#define LCD_ASSET_BASE_PATH "/littlefs/lcd/"
/*
* 静态表只保存资源元数据和路径,不在此文件执行 PNG 解码。
* 资源目录名称是 platform 内部播放原语,表情语义映射仍由 domain 层维护。
*/
static const lcd_direct_image_t s_default_image = {
.path = LCD_ASSET_BASE_PATH "buyunqian1.png",
};
@@ -254,6 +258,7 @@ static const lcd_direct_image_t *lcd_direct_frame_from_array(const lcd_direct_im
size_t frame_count,
uint8_t frame_index)
{
/* 数组以调用方传入的显式长度为界,避免依赖哨兵项。 */
if (frame_index >= frame_count) {
return NULL;
}
@@ -267,6 +272,7 @@ const lcd_direct_image_t *lcd_direct_default_image(void)
const lcd_direct_image_t *lcd_direct_asset_frame(const char *asset_dir, uint8_t frame_index)
{
/* 目录名精确匹配;未知目录不回退默认资源,以便暴露配置错误。 */
if (asset_dir == NULL) {
return NULL;
}
@@ -442,6 +448,7 @@ const lcd_direct_image_t *lcd_direct_asset_frame(const char *asset_dir, uint8_t
const lcd_direct_icon_t *lcd_direct_icon(uint16_t icon_id)
{
/* icon_id 是紧凑表索引;越界时不尝试读取静态数组。 */
if (icon_id >= (sizeof(s_wuxing_icons) / sizeof(s_wuxing_icons[0]))) {
return NULL;
}
@@ -32,6 +32,12 @@
#include "driver/i2s_std.h"
#include "driver/i2c_master.h"
/*
* Kconfig 兼容默认值:仅在配置项缺失时生效。后面的编译期约束仍强制语音链路使用
* 16 kHz、双物理时隙和16位 PCM,因此默认值不等于所有板型都可直接运行。
* These are compatibility fallbacks for missing Kconfig values; compile-time
* checks below still enforce the audio format required by voice/AEC processing.
*/
#ifndef CONFIG_MULTIMODAL_AUDIO_ENABLE_CODEC_PATH
#define CONFIG_MULTIMODAL_AUDIO_ENABLE_CODEC_PATH 1
#endif
@@ -68,6 +74,7 @@
#define CONFIG_MULTIMODAL_AUDIO_I2S_PORT 0
#endif
/* I2C/I2S GPIO 必须由板级 Kconfig 明确提供,避免错误默认引脚驱动真实硬件。 */
#ifndef CONFIG_MULTIMODAL_AUDIO_I2S_MCLK_GPIO
#error "CONFIG_MULTIMODAL_AUDIO_I2S_MCLK_GPIO must be defined in Kconfig"
#endif
@@ -97,6 +104,10 @@
#endif
#ifndef CONFIG_MULTIMODAL_AUDIO_I2S_DMA_DESC_NUM
/*
* I2S DMA 默认4个描述符、每描述符160帧:增加可提高抗调度抖动能力,但会占用更多
* DMA 内存并增加排队延迟;减少则降低内存占用,但更容易出现采集/播放 underrun。
*/
#define CONFIG_MULTIMODAL_AUDIO_I2S_DMA_DESC_NUM 4
#endif
@@ -105,6 +116,7 @@
#endif
#ifndef CONFIG_MULTIMODAL_AUDIO_OUT_VOLUME
/* codec 初始增益属于硬件安全基线;调整前需同时检查削波、啸叫和扬声器功率。 */
#define CONFIG_MULTIMODAL_AUDIO_OUT_VOLUME 60
#endif
@@ -113,6 +125,10 @@
#endif
#ifndef CONFIG_MULTIMODAL_AUDIO_MIC_CHANNEL
/*
* 双时隙输入中,麦克风与 AEC 回采必须使用不同槽位;滤波长度增大可覆盖更长回声,
* 但会增加运算量和工作内存。编译期检查防止两个通道配置为同一槽位。
*/
#define CONFIG_MULTIMODAL_AUDIO_MIC_CHANNEL 0
#endif
@@ -129,6 +145,11 @@
#endif
#ifndef CONFIG_MULTIMODAL_AUDIO_DOWNSTREAM_IDLE_MUTE_MS
/*
* 下行播放时序权衡:首次积累 PREBUFFER 后开声;断流超过 REBUFFER_GAP 后重新缓冲,
* 最大积累量受 MAX_REBUFFER 限制以避免延迟持续增长;正常排空后等待 IDLE_MUTE
* 再静音以减少爆音,活动响应长时间无数据则按 ACTIVE_STALL 判定卡顿。
*/
#define CONFIG_MULTIMODAL_AUDIO_DOWNSTREAM_IDLE_MUTE_MS 250
#endif
@@ -153,6 +174,11 @@
#endif
#ifndef CONFIG_MULTIMODAL_AUDIO_DOWNSTREAM_PLAYBACK_TASK_STACK
/*
* 下行任务承担 Opus 解码和 codec 写入,栈下限保护最深调用链。队列长度和单包容量
* 共同决定最坏内存占用;配置值会被归一化到 MIN/CAP 范围,避免过小不可运行或
* 过大耗尽 PSRAM。修改时应同时评估端到端延迟和网络突发大小。
*/
#define CONFIG_MULTIMODAL_AUDIO_DOWNSTREAM_PLAYBACK_TASK_STACK 12288
#endif
@@ -173,6 +199,10 @@
#define MULTIMODAL_AUDIO_DOWNSTREAM_PLAYBACK_PACKET_CAP_BYTES 8192U
#ifndef CONFIG_MULTIMODAL_AUDIO_UPLINK_TASK_STACK
/*
* 上行采集栈包含 I2S、AEC 和 Opus 编码调用链,因此基线大于纯发送任务。发送队列
* 将网络抖动与实时采集隔离;5ms 入队等待刻意选择“及时丢包”而非阻塞采集。
*/
#define CONFIG_MULTIMODAL_AUDIO_UPLINK_TASK_STACK 32768
#endif
@@ -199,6 +229,10 @@
#endif
#ifndef CONFIG_MULTIMODAL_AUDIO_INIT_RETRY_COUNT
/*
* 初始化重试前先检查最大连续 DMA/内部内存块,避免在堆碎片严重时构建出半初始化
* codec。阈值是创建 I2S/codec 控制对象所需的安全水位,不是总空闲内存要求。
*/
#define CONFIG_MULTIMODAL_AUDIO_INIT_RETRY_COUNT 1
#endif
@@ -211,6 +245,10 @@
#endif
#ifndef CONFIG_MULTIMODAL_OPUS_FRAME_DURATION_MS
/*
* 服务端协议要求 60ms raw-Opus 帧。24kbps、低复杂度和关闭 VBR/DTX/FEC 是当前
* 实时性与 MCU 负载基线;改变任一项需同步验证服务端解码、包容量和网络带宽。
*/
#define CONFIG_MULTIMODAL_OPUS_FRAME_DURATION_MS 60
#endif
@@ -259,6 +297,7 @@
#endif
#ifndef CONFIG_MULTIMODAL_AUDIO_TASK_CORE
/* 音频任务默认固定到本地实时核心;负数表示由调度器决定,需重新验证抖动。 */
#define CONFIG_MULTIMODAL_AUDIO_TASK_CORE 1
#endif
@@ -268,6 +307,10 @@
#define MULTIMODAL_AUDIO_CORE_ID ((BaseType_t)CONFIG_MULTIMODAL_AUDIO_TASK_CORE)
#endif
/*
* 解码缓冲包含最小工作区和保护余量;TX bounce 缓冲用于处理 codec 短写。
* AEC 缓冲按16字节对齐以满足 DSP/SIMD 访问要求。
*/
#define MULTIMODAL_PCM_SAMPLE_BYTES 2U
#define MULTIMODAL_MIN_DECODER_PCM_BUFFER_BYTES 2048U
#define MULTIMODAL_DECODER_BUFFER_GUARD_BYTES 512U
@@ -280,6 +323,7 @@
static const char *TAG = "multimodal_audio";
static portMUX_TYPE s_audio_runtime_lock = portMUX_INITIALIZER_UNLOCKED;
/* 音频上下行共享 codec/I2S 资源,因此所有生命周期状态集中保存在单一上下文中。 */
typedef struct {
const audio_codec_data_if_t *data_if;
const audio_codec_ctrl_if_t *ctrl_if;
@@ -355,6 +399,7 @@ typedef struct {
} multimodal_audio_ctx_t;
static multimodal_audio_ctx_t s_audio;
/* 下行播放以 generation 标识会话,终止事件只唤醒等待同一代的调用方。 */
static multimodal_audio_downstream_playback_callback_t s_downstream_playback_cb;
static void *s_downstream_playback_cb_ctx;
static SemaphoreHandle_t s_downstream_playback_completion_sem;
@@ -423,10 +468,17 @@ typedef struct {
uint8_t *data;
} downstream_packet_t;
/* Split implementation by concern while keeping a single translation unit:
* - helpers: memory, config normalization, utility helpers
* - codec: codec/i2c/i2s/opus setup and teardown
* - runtime: uplink/downlink processing and public APIs
/*
* 实现文件拆分规则:
* - 下列 *_impl.c 不能单独加入 CMake 或独立编译,只能由本文件按当前顺序包含;
* - 它们共享本文件上方定义的 static 类型、状态、宏和前置声明;
* - 拆分仅为了按职责降低阅读长度,不形成新的模块或链接边界;
* - include 顺序有意为 helpers -> codec -> debug -> runtime,后面的实现可以使用
* 前面已经定义的内部辅助函数,调整顺序前必须检查可见声明和依赖方向。
*
* Included *_impl.c files are not standalone translation units. They share this
* file's static state and are split only for readability. Their include order is
* dependency-sensitive and must not be changed without reviewing symbol visibility.
*/
#include "audio_pipeline/audio_pipeline_helpers_impl.c"
@@ -1,9 +1,10 @@
/*
* Audio debug dump - streams uplink PCM and/or Opus packets over UDP
* to a PC on the same LAN for offline/real-time analysis.
*
* Included by audio_pipeline.c (single translation unit).
* Guarded by CONFIG_MULTIMODAL_AUDIO_DEBUG_DUMP.
* 音频调试转储:通过 UDP 将上行 PCM 和/或 Opus 包发送到同一局域网的电脑,
* 供离线或实时分析。本文件不能单独编译;由 audio_pipeline.c 在 codec 之后、
* runtime 之前包含,共享 TAG 和音频内部声明。拆分仅为了阅读,include 顺序影响
* 后续 runtime 对这些静态调试函数的可见性。功能由配置宏完全裁剪。
* This file is a non-standalone include between codec and runtime. It shares the
* parent translation unit, and its position makes debug helpers visible to runtime.
*
* PC-side playback:
* ffplay -nodisp -f s16le -ar 16000 -ac 1 udp://0.0.0.0:7200
@@ -27,6 +28,7 @@
#define CONFIG_MULTIMODAL_AUDIO_DEBUG_DUMP_OPUS_PORT 7201
#endif
/* 1400字节低于常见以太网 MTU,为 UDP/IP 头留余量以尽量避免 IP 分片。 */
#define AUDIO_DEBUG_DUMP_MAX_UDP_PAYLOAD 1400U
static int s_debug_dump_sock = -1;
@@ -37,6 +39,7 @@ static struct sockaddr_in s_debug_dump_opus_addr;
static esp_err_t audio_debug_dump_init(void)
{
/* 使用非阻塞 UDP,调试接收端离线时不得拖慢实时采集链路。 */
if (s_debug_dump_sock >= 0) {
return ESP_OK;
}
@@ -94,6 +97,7 @@ static void audio_debug_dump_deinit(void)
static void audio_debug_dump_pcm(const int16_t *pcm, size_t pcm_bytes)
{
/* 按保守 UDP payload 大小分片,降低 IP 分片和局域网丢包概率。 */
if (s_debug_dump_sock < 0 || pcm == NULL || pcm_bytes == 0U) {
return;
}
@@ -114,6 +118,7 @@ static void audio_debug_dump_pcm(const int16_t *pcm, size_t pcm_bytes)
static void audio_debug_dump_opus(const uint8_t *data, size_t len)
{
/* 每个 Opus 包保持单 datagram 边界,便于电脑端恢复编码帧。 */
#if CONFIG_MULTIMODAL_AUDIO_DEBUG_DUMP_OPUS_ENABLE
if (s_debug_dump_sock < 0 || data == NULL || len == 0U) {
return;
@@ -127,7 +132,7 @@ static void audio_debug_dump_opus(const uint8_t *data, size_t len)
#endif
}
#else /* !CONFIG_MULTIMODAL_AUDIO_DEBUG_DUMP */
#else /* 未启用调试转储时提供零开销内联空实现,调用点无需条件编译。 */
static inline esp_err_t audio_debug_dump_init(void)
{
@@ -1,3 +1,11 @@
/*
* codec/I2C/I2S/Opus 资源构建与逆序清理。
* 不能单独编译;由 audio_pipeline.c 在 helpers 之后包含,共享 s_audio 和内部类型。
* 拆分仅为了阅读,runtime 依赖本文件定义的硬件/编解码初始化与清理函数,因此
* include 顺序不可随意调整。每个 cleanup 都可处理部分初始化状态。
* This non-standalone implementation shares audio_pipeline.c static state and
* must remain after helpers and before runtime.
*/
static void cleanup_codec_path(multimodal_audio_ctx_t *ctx)
{
if (!ctx) {
@@ -36,6 +44,7 @@ static void cleanup_codec_path(multimodal_audio_ctx_t *ctx)
static void cleanup_opus_path(multimodal_audio_ctx_t *ctx)
{
/* 编解码器和关联缓冲按所有权逐项释放,并将句柄清零以支持重复清理。 */
if (!ctx) {
return;
}
@@ -121,6 +130,7 @@ static void deinit_buses(multimodal_audio_ctx_t *ctx)
static esp_err_t init_i2c_bus(multimodal_audio_ctx_t *ctx)
{
/* 先建立总线,再探测候选地址;探测地址和 codec 控制地址分别保存。 */
if (!ctx) {
return ESP_ERR_INVALID_ARG;
}
@@ -189,6 +199,7 @@ static esp_err_t init_i2c_bus(multimodal_audio_ctx_t *ctx)
static esp_err_t init_i2s_bus(multimodal_audio_ctx_t *ctx)
{
/* TX/RX 共用时钟配置,但保留独立通道句柄以支持全双工采集与播放。 */
if (!ctx) {
return ESP_ERR_INVALID_ARG;
}
@@ -264,6 +275,7 @@ static esp_err_t init_i2s_bus(multimodal_audio_ctx_t *ctx)
static esp_err_t create_codec_path(multimodal_audio_ctx_t *ctx)
{
/* 数据、控制和 GPIO 接口均由 codec device 引用,设备销毁前不得提前释放。 */
if (!ctx) {
return ESP_ERR_INVALID_ARG;
}
@@ -451,6 +463,7 @@ static esp_err_t open_codec_device(multimodal_audio_ctx_t *ctx)
static esp_err_t ensure_downstream_buffers(multimodal_audio_ctx_t *ctx, size_t pcm_mono_bytes_needed)
{
/* 根据解码帧动态扩容;只有新缓冲分配成功后才替换旧指针。 */
if (!ctx || pcm_mono_bytes_needed == 0) {
return ESP_ERR_INVALID_ARG;
}
@@ -508,6 +521,7 @@ static esp_err_t ensure_downstream_buffers(multimodal_audio_ctx_t *ctx, size_t p
static esp_err_t init_opus_codec(multimodal_audio_ctx_t *ctx)
{
/* 编码器和解码器必须成对就绪,任一失败都会回滚整个 Opus 路径。 */
if (!ctx) {
return ESP_ERR_INVALID_ARG;
}
@@ -675,6 +689,7 @@ static void expand_mono_to_multichannel_16(const int16_t *input,
int16_t *output,
size_t frames)
{
/* 单声道解码结果复制到 codec 输出的每个声道,不在此处改变采样幅度。 */
if (!input || !output || channels == 0) {
return;
}
@@ -1,5 +1,14 @@
/*
* 音频流水线通用辅助实现。
* 不能单独编译;由 audio_pipeline.c 首先包含,并共享其 static 类型、宏和状态。
* 拆分仅为了阅读,当前顺序使 codec/runtime 可以使用这里定义的内存、队列、任务
* 和配置归一化函数;改变 include 顺序可能导致声明不可见或依赖倒置。
* This file is included first by audio_pipeline.c, shares its static state, and
* must not be compiled independently. Include order is dependency-sensitive.
*/
static bool ensure_downstream_playback_queue_mutex(void)
{
/* 首次使用时串行创建互斥锁,避免两个调用方各自创建并泄漏一个实例。 */
if (s_downstream_playback_queue_mutex != NULL) {
return true;
}
@@ -57,6 +66,7 @@ static void unlock_downstream_playback_queue(void)
static void *audio_psram_first_malloc(size_t size)
{
/* 大块音频缓冲优先放 PSRAM,失败后回退内部 8-bit RAM 保证兼容无 PSRAM 板型。 */
if (size == 0U) {
return NULL;
}
@@ -164,6 +174,7 @@ static void log_heap_snapshot(const char *phase)
static bool audio_init_heap_guard_passed(void)
{
/* 初始化前预留内部 RAM 安全水位,避免 codec 创建成功后网络栈无可用连续块。 */
size_t largest_dma = heap_caps_get_largest_free_block(MALLOC_CAP_DMA);
size_t largest_internal = heap_caps_get_largest_free_block(MALLOC_CAP_INTERNAL | MALLOC_CAP_8BIT);
@@ -265,6 +276,7 @@ static esp_audio_err_t open_opus_encoder_with_fallback(multimodal_audio_ctx_t *c
bool requested_dtx,
bool requested_fec)
{
/* 按候选采样率/帧长逐一尝试,成功后将实际参数写回统一上下文。 */
if (!ctx) {
return ESP_AUDIO_ERR_INVALID_PARAMETER;
}
@@ -539,6 +551,7 @@ static TickType_t get_uplink_send_queue_timeout_ticks(void)
static QueueHandle_t create_uplink_send_queue(UBaseType_t queue_len, bool *out_with_caps)
{
/* 队列控制块与存储区尽量放外部内存,并记录分配方式供销毁路径正确释放。 */
if (out_with_caps != NULL) {
*out_with_caps = false;
}
@@ -672,6 +685,7 @@ static BaseType_t create_audio_task_with_caps(TaskFunction_t task,
bool prefer_psram_stack,
BaseType_t core_id)
{
/* 优先使用带内存能力的静态任务创建;不支持时回退标准 FreeRTOS 动态任务。 */
#if ( configSUPPORT_STATIC_ALLOCATION == 1 )
#if (defined(CONFIG_FREERTOS_TASK_CREATE_ALLOW_EXT_MEM) && CONFIG_FREERTOS_TASK_CREATE_ALLOW_EXT_MEM) || \
defined(CONFIG_SPIRAM_ALLOW_STACK_EXTERNAL_MEMORY)
@@ -747,6 +761,7 @@ static void get_i2c_addr_candidates(uint8_t raw,
uint8_t *probe_b,
uint8_t *ctrl_b)
{
/* 同时兼容 Kconfig 中填写 7 位地址和包含 R/W 位的 8 位地址。 */
*probe_a = (uint8_t)(raw & 0x7FU);
*ctrl_a = i2c_addr_ctrl_from_7bit(*probe_a);
*ctrl_b = (uint8_t)(raw & 0xFEU);
@@ -5,6 +5,17 @@ typedef struct {
#define MULTIMODAL_AUDIO_UPLINK_STATS_LOG_INTERVAL_MS 5000U
/*
* 音频运行时状态机。
* 不能单独编译;由 audio_pipeline.c 最后包含,共享前述 helpers/codec 定义及全部
* static 状态。拆分仅为了阅读,include 顺序决定内部函数和类型是否可见。
* This file is the final non-standalone include of audio_pipeline.c and depends
* on helper/codec definitions that precede it.
*
* 上行链路:I2S 采集 -> 选取/清理麦克风声道 -> Opus 编码 -> 发送队列。
* 下行链路:Opus 包队列 -> 解码 -> 预缓冲/恢复判断 -> codec 播放 -> 空闲静音。
* 所有任务退出路径都负责清除自身句柄,停止 API 使用有界等待并提供兜底清理。
*/
static uint32_t abs_i16_to_u32(int16_t sample)
{
int32_t value = sample;
@@ -195,6 +206,7 @@ static bool audio_consume_first_network_packet(multimodal_audio_ctx_t *ctx,
static void notify_pcm_observer(const int16_t *pcm, size_t samples)
{
/* in_flight 计数让 unregister 能等待正在执行的观察者,同时回调在锁外运行。 */
multimodal_audio_pcm_observer_t observer = NULL;
void *observer_ctx = NULL;
@@ -438,6 +450,7 @@ static void free_downstream_packet(downstream_packet_t *packet)
static void drain_downstream_playback_queue_handle(QueueHandle_t queue)
{
/* 队列保存的是拥有 payload 的指针,排空时必须逐包释放而不能只 reset 队列。 */
if (queue == NULL) {
return;
}
@@ -881,6 +894,7 @@ static esp_err_t encode_and_queue_mono_pcm(multimodal_audio_ctx_t *ctx,
const int16_t *pcm,
size_t pcm_bytes)
{
/* 编码成功只表示生成 Opus 包;队列满时按实时策略丢包,绝不阻塞采集任务。 */
if (!ctx || !pcm || pcm_bytes != ctx->uplink_mono_bytes) {
return ESP_ERR_INVALID_ARG;
}
@@ -944,6 +958,7 @@ static esp_err_t append_and_queue_mono_pcm(
const int16_t *pcm,
size_t pcm_bytes)
{
/* I2S 块可能不对齐 Opus 帧,尾部 PCM 暂存在 fill 缓冲并与下一块拼接。 */
if (ctx == NULL || pcm == NULL || pcm_bytes == 0U ||
ctx->uplink_mono_buf == NULL ||
ctx->uplink_mono_buf_capacity_bytes != ctx->uplink_mono_bytes) {
@@ -982,6 +997,7 @@ static esp_err_t append_and_queue_mono_pcm(
static void uplink_send_task(void *arg)
{
/* 网络发送与采集解耦,慢连接只消耗有界队列容量,不拖慢 I2S 读取。 */
(void)arg;
multimodal_audio_ctx_t *ctx = &s_audio;
@@ -1041,6 +1057,7 @@ static void uplink_send_task(void *arg)
static void uplink_capture_task(void *arg)
{
/* 采集循环先完成声道映射/AEC,再通知 PCM 观察者并编码网络音频。 */
(void)arg;
multimodal_audio_ctx_t *ctx = &s_audio;
@@ -1143,6 +1160,7 @@ static void uplink_capture_task(void *arg)
static esp_err_t ensure_uplink_worker_tasks(multimodal_audio_ctx_t *ctx)
{
/* 发送任务先于采集任务启动;若后者创建失败,立即回收前者及队列。 */
if (ctx == NULL) {
return ESP_ERR_INVALID_ARG;
}
@@ -1208,6 +1226,7 @@ static esp_err_t ensure_uplink_worker_tasks(multimodal_audio_ctx_t *ctx)
esp_err_t multimodal_audio_init(void)
{
/* 初始化按总线、codec、Opus、缓冲顺序推进,失败统一逆序回滚到干净状态。 */
#if !CONFIG_MULTIMODAL_AUDIO_ENABLE_CODEC_PATH
ESP_LOGW(TAG, "Codec path disabled by configuration");
return ESP_ERR_NOT_SUPPORTED;
@@ -1340,6 +1359,7 @@ esp_err_t multimodal_audio_init(void)
void multimodal_audio_deinit(void)
{
/* deinit 先阻止新工作进入,再停止上下行任务,最后释放共享硬件资源。 */
if (!s_audio.initialized && !s_audio.opus_ready) {
return;
}
@@ -1496,6 +1516,7 @@ esp_err_t multimodal_audio_unregister_pcm_observer(
esp_err_t multimodal_audio_start_uplink(multimodal_audio_uplink_cb_t cb, void *cb_ctx)
{
/* 回调在任务启动前发布;任一启动失败都会撤销回调,避免半启动状态。 */
if (!s_audio.initialized || !s_audio.codec_dev || !s_audio.opus_enc ||
!s_audio.uplink_codec_buf || !s_audio.uplink_mono_buf || !s_audio.uplink_opus_buf) {
return ESP_ERR_INVALID_STATE;
@@ -1540,6 +1561,7 @@ esp_err_t multimodal_audio_start_uplink(multimodal_audio_uplink_cb_t cb, void *c
esp_err_t multimodal_audio_stop_uplink(int timeout_ms)
{
/* 先协作请求退出并等待两个任务;超时后保留明确错误而不伪装成已停止。 */
if (timeout_ms < 0) {
return ESP_ERR_INVALID_ARG;
}
@@ -1606,6 +1628,7 @@ static void snapshot_downstream_playback_state(uint32_t *out_generation,
static uint32_t advance_downstream_playback_generation_locked(void)
{
/* generation 跳过 0,使零值可以持续表示“尚未建立播放会话”。 */
s_audio.downstream_playback_generation++;
if (s_audio.downstream_playback_generation == 0U) {
s_audio.downstream_playback_generation = 1U;
@@ -1618,6 +1641,7 @@ static bool claim_downstream_response_terminal(uint32_t expected_generation,
bool invalidate_generation,
uint32_t *out_terminal_generation)
{
/* 终止事件只能被同一 generation 首次领取,屏蔽迟到包触发的重复完成通知。 */
if (out_terminal_generation == NULL) {
return false;
}
@@ -1696,6 +1720,7 @@ static void dispatch_downstream_playback_event(
multimodal_audio_downstream_playback_event_type_t type,
uint32_t playback_generation)
{
/* 先更新等待者所需的终止快照并释放信号量,再在锁外调用外部回调。 */
bool terminal = type == MULTIMODAL_AUDIO_DOWNSTREAM_PLAYBACK_COMPLETED ||
type == MULTIMODAL_AUDIO_DOWNSTREAM_PLAYBACK_STOPPED ||
type == MULTIMODAL_AUDIO_DOWNSTREAM_PLAYBACK_STALLED;
@@ -1838,6 +1863,7 @@ static void abort_downstream_response_after_active_stall(multimodal_audio_ctx_t
uint32_t idle_ms,
uint32_t expected_generation)
{
/* 活跃响应长时间无音频视为卡顿:停止本代播放,但不误终止下一代响应。 */
(void)ctx;
uint32_t stopped_generation = 0U;
if (!claim_downstream_response_terminal(expected_generation,
@@ -1883,6 +1909,7 @@ static esp_err_t set_downstream_output_mute_if_needed(multimodal_audio_ctx_t *ct
bool mute,
const char *reason)
{
/* 缓存 codec 静音状态,只有目标值变化时才进行控制总线写入。 */
if (ctx == NULL || ctx->codec_dev == NULL) {
return ESP_ERR_INVALID_STATE;
}
@@ -1983,6 +2010,7 @@ static void restart_downstream_idle_mute_timer(multimodal_audio_ctx_t *ctx,
static void downstream_idle_mute_timer_cb(void *arg)
{
/* 定时器回调只做状态判断和轻量控制,不销毁播放任务或队列。 */
multimodal_audio_ctx_t *ctx = (multimodal_audio_ctx_t *)arg;
if (ctx == NULL) {
return;
@@ -2114,6 +2142,7 @@ static esp_err_t ensure_downstream_tx_bounce_buffer(multimodal_audio_ctx_t *ctx)
static int write_downstream_pcm_to_codec(multimodal_audio_ctx_t *ctx, const void *data, size_t bytes)
{
/* codec 短写通过 bounce 缓冲循环补齐,避免丢失一个解码帧的尾部样本。 */
if (ctx == NULL || ctx->codec_dev == NULL || data == NULL || bytes == 0U || bytes > INT_MAX) {
return ESP_CODEC_DEV_INVALID_ARG;
}
@@ -2233,6 +2262,7 @@ static esp_err_t stop_downstream_idle_mute_task(multimodal_audio_ctx_t *ctx, int
static void downstream_playback_task(void *arg)
{
/* 预缓冲达到阈值后才取消静音;发生队列断流则重新进入缓冲态以减少爆音。 */
multimodal_audio_ctx_t *ctx = (multimodal_audio_ctx_t *)arg;
if (ctx == NULL) {
delete_audio_task_with_caps(NULL);
@@ -2547,6 +2577,7 @@ static esp_err_t stop_downstream_playback_task(multimodal_audio_ctx_t *ctx,
int timeout_ms,
bool keep_stopping)
{
/* 停止请求、等待和强制删除共享同一超时预算,退出后排空所有未播放包。 */
if (ctx == NULL) {
return ESP_ERR_INVALID_ARG;
}
@@ -2615,6 +2646,7 @@ static esp_err_t stop_downstream_playback_task(multimodal_audio_ctx_t *ctx,
static esp_err_t reset_downstream_decoder(multimodal_audio_ctx_t *ctx)
{
/* 每个新响应重置 Opus 解码历史,防止上一段语音的状态污染下一段。 */
if (ctx == NULL || ctx->opus_sample_rate == 0U || ctx->opus_frame_duration_ms == 0U) {
return ESP_OK;
}
@@ -2678,6 +2710,7 @@ static esp_err_t handle_downstream_opus_sync(const uint8_t *data,
size_t len,
uint32_t expected_generation)
{
/* 同步解码路径校验输出大小并扩展声道,实际 codec 写入仍由播放任务串行完成。 */
multimodal_audio_ctx_t *ctx = &s_audio;
if (!ctx->initialized || !ctx->codec_dev || !ctx->opus_dec) {
return ESP_ERR_INVALID_STATE;
@@ -2892,6 +2925,7 @@ static esp_err_t handle_downstream_opus_sync(const uint8_t *data,
esp_err_t multimodal_audio_handle_downstream_opus(const uint8_t *data, size_t len)
{
/* 包被深拷贝进有界队列;调用返回后不再引用 WebSocket 接收缓冲区。 */
multimodal_audio_ctx_t *ctx = &s_audio;
if (!ctx->initialized || !ctx->codec_dev || !ctx->opus_dec) {
return ESP_ERR_INVALID_STATE;
@@ -3003,6 +3037,7 @@ esp_err_t multimodal_audio_handle_downstream_opus(const uint8_t *data, size_t le
esp_err_t multimodal_audio_begin_downstream_response(
uint32_t *out_playback_generation)
{
/* 建立新 generation、清除旧终止快照并启动播放任务,之后才能接收该代数据。 */
multimodal_audio_ctx_t *ctx = &s_audio;
if (out_playback_generation == NULL) {
return ESP_ERR_INVALID_ARG;
@@ -3098,6 +3133,7 @@ esp_err_t multimodal_audio_begin_downstream_response(
esp_err_t multimodal_audio_finish_downstream_response(void)
{
/* finish 仅声明不会再到包;真正完成需等待队列和 codec 输出全部排空。 */
multimodal_audio_ctx_t *ctx = &s_audio;
if (!ctx->initialized || ctx->codec_dev == NULL) {
return ESP_ERR_INVALID_STATE;
@@ -3129,6 +3165,7 @@ esp_err_t multimodal_audio_wait_downstream_playback_complete(
uint32_t playback_generation,
int timeout_ms)
{
/* 等待前先检查终止快照,避免完成事件先于等待者注册造成永久错过。 */
if (playback_generation == 0U || timeout_ms < 0) {
return ESP_ERR_INVALID_ARG;
}
@@ -18,6 +18,7 @@
#include "esp_websocket_client.h"
#include "soc/soc_caps.h"
/* 连接默认值只在 Kconfig 缺失时启用;URI 和鉴权仍由部署环境及 start 参数决定。 */
#ifndef CONFIG_MULTIMODAL_WS_URI
#define CONFIG_MULTIMODAL_WS_URI "wss://dashscope.aliyuncs.com/api-ws/v1/inference"
#endif
@@ -31,6 +32,10 @@
#endif
#ifndef CONFIG_MULTIMODAL_WEBSOCKET_TASK_STACK_BYTES
/*
* client、dispatch、文本和 Opus 处理具有不同调用深度,分别配置栈以避免用最大值
* 覆盖所有任务。栈优先放 PSRAM,内部 RAM 回退受后面的 MIN/CAP 约束。
*/
#define CONFIG_MULTIMODAL_WEBSOCKET_TASK_STACK_BYTES 32768
#endif
@@ -51,6 +56,10 @@
#endif
#ifndef CONFIG_MULTIMODAL_WS_MSG_QUEUE_LEN
/*
* 接收队列吸收 ESP 事件线程与 dispatch 消费速度的短期差异。512 是突发容忍上限;
* 增大会线性增加队列及 payload 峰值内存,不能作为处理长期拥塞的手段。
*/
#define CONFIG_MULTIMODAL_WS_MSG_QUEUE_LEN 512
#endif
@@ -59,6 +68,10 @@
#endif
#ifndef CONFIG_MULTIMODAL_WS_PING_INTERVAL_SEC
/*
* WebSocket ping 用于应用层存活检测,TCP keepalive 用于发现半开连接。两者故意并存:
* ping 更快反映服务端会话状态,keepalive 覆盖底层连接失效。减小周期会增加无线唤醒。
*/
#define CONFIG_MULTIMODAL_WS_PING_INTERVAL_SEC 20
#endif
@@ -83,6 +96,10 @@
#endif
#ifndef CONFIG_MULTIMODAL_WS_REALTIME_LOCK_WAIT_MS
/*
* 实时音频发送只短暂等待发送锁和网络写入:宁可丢弃当前包,也不能反压采集任务。
* 普通文本使用更长超时和重试,以可靠性优先。
*/
#define CONFIG_MULTIMODAL_WS_REALTIME_LOCK_WAIT_MS 80
#endif
@@ -98,6 +115,7 @@
#define CONFIG_TQ_NETWORK_TASK_CORE 0
#endif
/* 网络工作默认固定到网络核心;配置为负数时取消亲和性,修改后需观察音频抖动。 */
#if CONFIG_TQ_NETWORK_TASK_CORE < 0
#define MULTIMODAL_WS_CORE_ID tskNO_AFFINITY
#else
@@ -107,10 +125,15 @@
#define WS_OPCODE_TEXT 0x1
#define WS_OPCODE_BINARY 0x2
/*
* 单个事件片段最多4KB;JSON 文本允许重组到8倍以容纳结构化响应,二进制音频保持
* 4KB 硬上限以控制实时内存。越界或分片不连续会丢弃整条消息。
*/
#define MULTIMODAL_WS_MSG_MAX_BYTES 4096U
#define WS_TEXT_REASSEMBLE_MAX_BYTES (MULTIMODAL_WS_MSG_MAX_BYTES * 8U)
#define WS_BINARY_REASSEMBLE_MAX_BYTES MULTIMODAL_WS_MSG_MAX_BYTES
/* 配置栈先限制到全局范围,再按任务角色应用更严格的最低值和 PSRAM 栈上限。 */
#define MULTIMODAL_WS_STACK_MIN 8192
#define MULTIMODAL_WS_STACK_MAX 32768
#define MULTIMODAL_WS_DISPATCH_STACK_MIN 4096
@@ -123,6 +146,10 @@
#define MULTIMODAL_WS_MSG_QUEUE_CAP 512U
/*
* 普通发送最多重试4次、间隔80ms,适合控制文本;实时文本/音频使用300ms级边界。
* 二进制 gap/summary 常量只控制诊断日志节流,不改变传输行为。
*/
#define MULTIMODAL_WS_SEND_TIMEOUT_MS 1200
#define MULTIMODAL_WS_SEND_RETRY_MAX 4
#define MULTIMODAL_WS_SEND_RETRY_DELAY_MS 80
@@ -130,6 +157,10 @@
#define MULTIMODAL_WS_TEXT_REALTIME_SEND_TIMEOUT_MS 300
#define MULTIMODAL_WS_BINARY_GAP_LOG_MS 600
#define MULTIMODAL_WS_BINARY_SUMMARY_INTERVAL 200U
/*
* timeout=0 的 stop 由独立清理任务接管;启动门限防止 start 在旧清理流程尚未登记
* 任务句柄时抢先创建新 client。工作任务停止等待仍是有界的1200ms。
*/
#define MULTIMODAL_WS_ASYNC_STOP_TASK_STACK_BYTES 4096
#define MULTIMODAL_WS_ASYNC_STOP_START_GATE_WAIT_MS 5000
@@ -137,12 +168,14 @@
#define MULTIMODAL_WS_REALTIME_SEND_TIMEOUT_MS CONFIG_MULTIMODAL_WS_REALTIME_SEND_TIMEOUT_MS
#define MULTIMODAL_WS_WORKER_STOP_WAIT_MS 1200
/* mbedTLS 非阻塞 WANT_WRITE/WANT_READ 错误,用于判定发送是否值得短暂重试。 */
#define MULTIMODAL_TLS_WANT_WRITE_ERR (-0x6880)
#define MULTIMODAL_TLS_WANT_READ_ERR (-0x6900)
#define MULTIMODAL_WS_TOKEN_MAX_LEN 256
static const char *TAG = "multimodal_ws";
/* WebSocket 事件数据先复制进队列,避免在 ESP 事件回调线程中执行耗时解析。 */
typedef struct {
uint8_t op_code;
uint8_t *data;
@@ -194,6 +227,7 @@ static bool s_ws_msg_queue_cap_warned;
static uint32_t s_ws_binary_event_enqueued;
static uint32_t s_ws_binary_worker_processed;
/* client、连接标志和停止标志必须作为一个状态快照读取,避免竞态判断。 */
static void websocket_event_handler(void *handler_args,
esp_event_base_t base,
int32_t event_id,
@@ -273,6 +307,7 @@ static esp_err_t websocket_send_binary_internal(const uint8_t *data,
static void set_event_callback(multimodal_ws_event_callback_t cb, void *user_data)
{
/* 注册信息在临界区内更新;实际回调始终使用锁外快照。 */
portENTER_CRITICAL(&s_event_cb_lock);
s_event_cb = cb;
s_event_cb_ctx = user_data;
@@ -355,9 +390,16 @@ static void ws_set_stop_requested(bool stop_requested)
portEXIT_CRITICAL(&s_client_state_lock);
}
/* Split implementation by concern while keeping a single translation unit:
* - workers: queue/task/stack helpers and worker pipeline logic
* - runtime: websocket lifecycle, event handling, send/stop public APIs
/*
* 实现文件拆分规则:
* - 下列 *_impl.c 不能单独加入 CMake 或独立编译,只能由本文件按当前顺序包含;
* - 两个文件共享本文件的 static client、锁、队列、重组缓冲及前置声明;
* - 拆分仅用于区分“工作管线”和“client 生命周期”,不存在链接边界;
* - workers 必须先于 runtime,因为 runtime 会调用前者定义的创建、发送和销毁函数。
*
* Included *_impl.c files are not standalone translation units. They share the
* static transport state above, and workers must precede runtime because runtime
* depends on worker-pipeline definitions.
*/
#include "transport_ws/transport_ws_workers_impl.c"
@@ -1,8 +1,18 @@
/*
* WebSocket client 生命周期和 ESP 事件桥接。
* 不能单独编译;由 transport_ws.c 在 workers 之后包含,共享主文件 static 状态并
* 调用 workers 中定义的辅助函数。拆分仅为了阅读,改变 include 顺序会破坏依赖。
* This non-standalone file is included after workers, shares transport_ws.c
* static state, and depends on symbols defined by the preceding include.
*
* start/stop 统一拥有 client、工作队列和回调注册;零超时停止转为异步清理任务。
*/
static void websocket_event_handler(void *handler_args,
esp_event_base_t base,
int32_t event_id,
void *event_data)
{
/* 事件 payload 在 ESP 回调返回后即失效,因此入队前必须完成深拷贝。 */
(void)handler_args;
(void)base;
@@ -123,6 +133,7 @@ esp_err_t multimodal_ws_start(const char *bearer_token,
multimodal_ws_event_callback_t cb,
void *user_data)
{
/* 新启动前清理上次失败遗留资源,鉴权 token 复制到模块私有定长缓冲区。 */
if (bearer_token == NULL || bearer_token[0] == '\0') {
return ESP_ERR_INVALID_ARG;
}
@@ -263,6 +274,7 @@ static bool ws_has_owned_resources(void)
static bool ws_claim_async_stop(void)
{
/* starting 标志作为一次性所有权声明,防止并发 stop 创建多个清理任务。 */
bool claimed = false;
portENTER_CRITICAL(&s_client_state_lock);
@@ -336,6 +348,7 @@ static bool ws_wait_async_stop_done_until(TickType_t deadline_tick)
static void ws_async_stop_task(void *arg)
{
/* 异步停止仍执行完整同步清理,完成后清除自身句柄供后续 start 使用。 */
(void)arg;
TaskHandle_t self = xTaskGetCurrentTaskHandle();
@@ -384,6 +397,7 @@ static esp_err_t ws_start_async_stop(void)
static esp_err_t multimodal_ws_stop_sync_until(TickType_t deadline_tick)
{
/* 顺序为停止 client、等待工作任务、销毁队列和清空回调,避免释放后仍有事件到达。 */
esp_websocket_client_handle_t client = ws_get_client_handle();
ws_set_stop_requested(true);
ws_set_connected(false);
@@ -433,6 +447,7 @@ static esp_err_t multimodal_ws_stop_sync_until(TickType_t deadline_tick)
esp_err_t multimodal_ws_stop(int timeout_ms)
{
/* timeout=0 仅发起清理;正数共享一个绝对截止时间等待已有或新建的停止流程。 */
if (timeout_ms < 0) {
return ESP_ERR_INVALID_ARG;
}
@@ -1,3 +1,12 @@
/*
* WebSocket 队列、工作任务、分片重组和发送策略实现。
* 不能单独编译;由 transport_ws.c 先于 runtime 包含,共享主文件的 client、锁、
* 队列和重组状态。拆分仅为了阅读,runtime 直接依赖本文件中定义的静态函数。
* This non-standalone file is included before runtime and shares all transport
* static state; include order is dependency-sensitive.
*
* ESP WebSocket 事件线程只负责复制消息;完整消息在 dispatch 任务中重组并回调。
*/
static bool is_retryable_send_error(int transport_ret, int saved_errno)
{
if (transport_ret == MULTIMODAL_TLS_WANT_WRITE_ERR ||
@@ -112,6 +121,7 @@ static BaseType_t create_ws_task_with_caps(TaskFunction_t task,
bool prefer_psram_stack,
BaseType_t core_id)
{
/* 优先把工作栈放入 PSRAM,创建失败时由上层候选策略缩小栈并回退内部 RAM。 */
#if ( configSUPPORT_STATIC_ALLOCATION == 1 )
#if (defined(CONFIG_FREERTOS_TASK_CREATE_ALLOW_EXT_MEM) && CONFIG_FREERTOS_TASK_CREATE_ALLOW_EXT_MEM) || \
defined(CONFIG_SPIRAM_ALLOW_STACK_EXTERNAL_MEMORY)
@@ -214,6 +224,7 @@ static QueueHandle_t create_ws_queue_with_caps(UBaseType_t queue_len,
static void free_ws_msg(ws_msg_t *msg)
{
/* 消息结构和 payload 由队列消费者独占,消费或丢弃后必须一起释放。 */
if (msg == NULL) {
return;
}
@@ -284,6 +295,7 @@ static void log_queue_drop(const char *queue_name, QueueHandle_t queue, uint8_t
static void emit_event(multimodal_ws_event_type_t type, const uint8_t *data, size_t data_len)
{
/* 回调注册先取快照,调用期间不持有注册锁,允许回调安全地重新注册。 */
multimodal_ws_event_callback_t cb = NULL;
void *cb_user_data = NULL;
snapshot_event_callback(&cb, &cb_user_data);
@@ -303,6 +315,7 @@ static void emit_event(multimodal_ws_event_type_t type, const uint8_t *data, siz
static void process_text_fragment(const ws_msg_t *msg)
{
/* 使用 payload_offset 校验连续性,乱序、越界或总长变化都会丢弃当前重组。 */
if (msg == NULL || msg->data == NULL || msg->data_len == 0U) {
return;
}
@@ -385,6 +398,7 @@ static void process_text_fragment(const ws_msg_t *msg)
static void process_binary_fragment(const ws_msg_t *msg)
{
/* 二进制与文本分别维护缓冲,避免两类分片交错到达时相互覆盖。 */
if (msg == NULL || msg->data == NULL || msg->data_len == 0U) {
return;
}
@@ -490,6 +504,7 @@ static bool wait_worker_task_exit(TaskHandle_t *task_handle, int timeout_ms)
static void ws_dispatch_task(void *arg)
{
/* 单消费者串行完成重组和事件分发,退出前清空残留消息并清除任务句柄。 */
(void)arg;
ws_msg_t msg = {0};
@@ -557,6 +572,7 @@ static void destroy_queue_with_messages(QueueHandle_t *queue, bool *queue_with_c
static bool destroy_worker_pipeline_until(TickType_t deadline_tick)
{
/* 先请求协作退出,截止时间内未结束时返回 false 交由上层执行确定性兜底。 */
s_ws_worker_stop_requested = true;
bool stopped_cleanly = true;
int task_timeout_ms = ws_stop_remaining_ms(deadline_tick);
@@ -736,6 +752,7 @@ static bool ws_transport_ready(void)
static esp_err_t start_websocket_client_with_fallback(void)
{
/* 按可用堆连续块重新排序栈候选值,降低内存碎片导致的启动失败概率。 */
int candidates[5] = {0};
size_t count = unique_stack_candidates(CONFIG_MULTIMODAL_WEBSOCKET_TASK_STACK_BYTES,
candidates,
@@ -916,6 +933,7 @@ static esp_err_t websocket_send_binary_internal(const uint8_t *data,
TickType_t send_timeout,
bool with_retry)
{
/* 发送锁把同一帧写入串行化;实时路径通过短超时主动丢弃而不是阻塞采集。 */
esp_websocket_client_handle_t client = ws_get_client_handle();
if (client == NULL || data == NULL || len == 0U) {
return ESP_ERR_INVALID_ARG;
@@ -25,6 +25,11 @@
#if CONFIG_TQ_BATTERY_ENABLE
/*
* 每次采16个样本并裁掉最低/最高各4个,剩余8个取均值;200us 间隔用于跨越短促
* 开关噪声。2500-4500mV 只是硬件合理性范围,不是 SOC 阈值;越界样本标记无效,
* domain 层不得把它直接解释为没电。
*/
#define BATTERY_ADC_SAMPLE_COUNT 16
#define BATTERY_ADC_TRIM_COUNT 4
#define BATTERY_VALID_MIN_MV 2500
@@ -43,6 +48,7 @@ static portMUX_TYPE s_state_lock = portMUX_INITIALIZER_UNLOCKED;
static uint16_t s_load_ref_count[PLATFORM_BATTERY_LOAD_COUNT];
static int64_t s_cooldown_until_us;
/* ADC 读取可能跨任务发生,使用有界互斥等待防止采样路径无限阻塞。 */
static bool lock_adc(void)
{
if (s_adc_lock == NULL) {
@@ -81,6 +87,7 @@ static void sort_ints(int *values, size_t count)
static esp_err_t create_calibration(adc_unit_t unit, adc_channel_t channel)
{
/* 优先曲线拟合,芯片不支持时回退到线性拟合校准方案。 */
#if ADC_CALI_SCHEME_CURVE_FITTING_SUPPORTED
{
adc_cali_curve_fitting_config_t config = {
@@ -204,6 +211,7 @@ esp_err_t platform_battery_init(void)
static void snapshot_load_state(bool *out_active, bool *out_cooldown)
{
/* active 表示当前高负载,cooldown 表示负载刚结束且电压尚未完全恢复。 */
bool active = false;
int64_t cooldown_until_us = 0;
@@ -223,6 +231,7 @@ static void snapshot_load_state(bool *out_active, bool *out_cooldown)
esp_err_t platform_battery_read_sample(platform_battery_sample_t *out_sample)
{
/* 多次采样排序后去除两端异常值,以降低电机和音频负载造成的瞬态噪声。 */
if (out_sample == NULL) {
return ESP_ERR_INVALID_ARG;
}
@@ -262,6 +271,7 @@ esp_err_t platform_battery_read_sample(platform_battery_sample_t *out_sample)
}
sort_ints(samples_mv, BATTERY_ADC_SAMPLE_COUNT);
/* 对称裁剪最高/最低样本后取均值,抑制开关噪声但保留缓慢电压变化。 */
int64_t sum_mv = 0;
for (size_t i = BATTERY_ADC_TRIM_COUNT;
i < BATTERY_ADC_SAMPLE_COUNT - BATTERY_ADC_TRIM_COUNT;
@@ -297,6 +307,7 @@ esp_err_t platform_battery_read_sample(platform_battery_sample_t *out_sample)
void platform_battery_load_begin(platform_battery_load_source_t source)
{
/* 饱和递增防止异常嵌套调用造成引用计数回绕。 */
if (source < 0 || source >= PLATFORM_BATTERY_LOAD_COUNT) {
return;
}
@@ -309,6 +320,7 @@ void platform_battery_load_begin(platform_battery_load_source_t source)
void platform_battery_load_end(platform_battery_load_source_t source)
{
/* 最后一个负载结束时启动冷却窗口,给电池端电压留出恢复时间。 */
if (source < 0 || source >= PLATFORM_BATTERY_LOAD_COUNT) {
return;
}
@@ -30,11 +30,17 @@
#error "CONFIG_TQ_POWER_HOLD_ACTIVE_LEVEL must be defined in Kconfig"
#endif
/* 提示音和运行日志使用独立分区/挂载点,避免日志写入影响只读媒体资源。 */
#define VOICE_PROMPT_DEFAULT_BASE_PATH "/littlefs/audio_prompts"
#define VOICE_PROMPT_LITTLEFS_BASE_PATH "/littlefs"
#define VOICE_PROMPT_LITTLEFS_PARTITION "gifpack"
#define PLATFORM_LOGFS_BASE_PATH "/logfs"
#define PLATFORM_LOGFS_PARTITION "logfs"
/*
* 提示音文件由4字节小端包长头和对应 raw-Opus payload 重复组成;单包上限2KB
* 防止损坏文件触发异常分配。帧时长按60ms估算播放总时长,并额外留3秒用于队列
* 排空、codec 尾部播放和调度抖动。
*/
#define VOICE_PROMPT_FRAME_HEADER_BYTES 4U
#define VOICE_PROMPT_MAX_OPUS_PACKET_BYTES 2048U
#define VOICE_PROMPT_OPUS_FRAME_MS 60U
@@ -48,6 +54,7 @@ static esp_err_t s_voice_prompt_littlefs_mount_result = ESP_ERR_INVALID_STATE;
static bool s_logfs_mount_attempted;
static esp_err_t s_logfs_mount_result = ESP_ERR_INVALID_STATE;
/* GPIO 掩码集中构造,负数配置代表该板型不使用电源保持引脚。 */
static uint64_t power_manager_gpio_bit_mask(int gpio_num)
{
if (gpio_num < 0) {
@@ -58,6 +65,7 @@ static uint64_t power_manager_gpio_bit_mask(int gpio_num)
static esp_err_t power_manager_configure_hold_gpio(void)
{
/* 配置只执行一次;GPIO 合法性在首次实际使用前统一校验。 */
if (CONFIG_TQ_POWER_HOLD_GPIO < 0) {
return ESP_OK;
}
@@ -96,6 +104,7 @@ static esp_err_t power_manager_configure_hold_gpio(void)
void power_manager_set_keep_awake(bool keep_awake)
{
/* 当前硬件通过保持电平锁住电源,false 由后续关机流程真正切断电源。 */
if (CONFIG_TQ_POWER_HOLD_GPIO < 0) {
(void)keep_awake;
return;
@@ -117,6 +126,7 @@ void platform_power_hold_init(void)
esp_err_t platform_mount_littlefs(void)
{
/* 记录首次挂载结果,使重复调用幂等且不会重复注册同一 VFS 路径。 */
if (s_voice_prompt_littlefs_mount_attempted) {
return s_voice_prompt_littlefs_mount_result;
}
@@ -160,6 +170,7 @@ esp_err_t platform_mount_littlefs(void)
esp_err_t platform_mount_logfs(void)
{
/* 日志分区与提示音分区独立挂载,任一失败不会覆盖另一分区状态。 */
if (s_logfs_mount_attempted) {
return s_logfs_mount_result;
}
@@ -203,6 +214,7 @@ esp_err_t platform_mount_logfs(void)
esp_err_t voice_prompt_play_file(const char *path)
{
/* 文件采用“长度头 + Opus 数据”帧格式,逐帧送入统一的下行播放管线。 */
if (path == NULL || path[0] == '\0') {
return ESP_ERR_INVALID_ARG;
}
@@ -16,11 +16,13 @@
#include "freertos/FreeRTOS.h"
#include "freertos/task.h"
/* 工厂 CLI 复用 UART0;2KB RX 环形缓冲吸收整行命令与短期主机发送突发。 */
#define PLATFORM_CONSOLE_UART_PORT UART_NUM_0
#define PLATFORM_CONSOLE_RX_BUFFER 2048
static const char *TAG = "platform_console";
/* UART0 驱动安装具有幂等性,已由其他启动路径安装也视为可用。 */
esp_err_t platform_console_install(void)
{
esp_err_t err = uart_driver_install(PLATFORM_CONSOLE_UART_PORT,
@@ -42,6 +44,7 @@ esp_err_t platform_console_install(void)
int platform_console_read_bytes(uint8_t *buf, size_t len, int timeout_ms)
{
/* 0 表示单次轮询,负数是无效参数;正数转换为 FreeRTOS 有界等待。 */
if (buf == NULL || len == 0U || timeout_ms < 0) {
return -1;
}
@@ -51,6 +54,7 @@ int platform_console_read_bytes(uint8_t *buf, size_t len, int timeout_ms)
void platform_console_write_line(const char *line)
{
/* 工厂协议按行发送,统一补充 CRLF,避免调用方重复处理行结束符。 */
if (line == NULL) {
return;
}
@@ -8,6 +8,7 @@
#include "driver/gpio.h"
/* 数字输入驱动只解释电气有效电平,不附加按键、充电器等业务含义。 */
esp_err_t platform_digital_input_driver_configure(const platform_digital_input_config_t *config)
{
if (config == NULL) {
@@ -31,6 +32,7 @@ esp_err_t platform_digital_input_driver_configure(const platform_digital_input_c
bool platform_digital_input_driver_is_active(const platform_digital_input_config_t *config)
{
/* 读取失败或配置无效时返回 false,调用方可通过初始化结果区分硬件错误。 */
if (config == NULL || config->gpio_num < 0 || config->gpio_num >= GPIO_NUM_MAX) {
return false;
}
@@ -26,12 +26,18 @@
#define PLATFORM_IO_EFFECTS_TASK_CORE ((BaseType_t)CONFIG_TQ_LOCAL_TASK_CORE)
#endif
/* GPIO4、GPIO5和22颗灯珠来自当前 PCB 布线/灯带布局,换板时必须同步核对原理图。 */
#define PLATFORM_RMT_GPIO GPIO_NUM_4
#define PLATFORM_PULSE_GPIO GPIO_NUM_5
#define PLATFORM_PULSE_HIGH_MS 500
#define PLATFORM_PULSE_LOW_MS 200
/*
* WS2812 每像素按 GRB 三字节发送;10MHz RMT 分辨率用于构造亚微秒0/1脉宽。
* 30ms 帧周期约33FPS,100ms 完成等待是硬件故障边界。亮度上限用于限制峰值电流,
* 下限保证呼吸动画暗部仍可见,调整时需复核电源预算和热量。
*/
#define PLATFORM_RMT_LED_COUNT 22
#define PLATFORM_RMT_LED_BYTES_PER_PIXEL 3
#define PLATFORM_RMT_RESOLUTION_HZ 10000000
@@ -45,6 +51,7 @@ static const char *TAG = "platform_io_effects";
static TaskHandle_t s_pulse_task;
static TaskHandle_t s_rmt_task;
/* WS2812 的 0/1/reset 时序预先编码为 RMT symbol,回调只负责逐字节展开。 */
static const rmt_symbol_word_t s_ws2812_zero = {
.level0 = 1,
.duration0 = PLATFORM_RMT_RESOLUTION_HZ / 10000000 * 3,
@@ -74,6 +81,7 @@ static size_t platform_ws2812_encoder_callback(const void *data,
bool *done,
void *arg)
{
/* RMT 每次至少需要八个空 symbol 才能原子编码一个颜色字节。 */
(void)arg;
if (symbols_free < 8U) {
@@ -103,6 +111,7 @@ static uint8_t scale_u8(uint8_t value, uint8_t scale)
static void hsv_to_rgb(uint8_t hue, uint8_t value, uint8_t *red, uint8_t *green, uint8_t *blue)
{
/* 使用整数 HSV 转换,避免动画任务引入浮点运算和额外栈占用。 */
uint8_t region = hue / 43U;
uint8_t remainder = (uint8_t)((hue - (region * 43U)) * 6U);
uint8_t p = 0U;
@@ -145,6 +154,7 @@ static void hsv_to_rgb(uint8_t hue, uint8_t value, uint8_t *red, uint8_t *green,
static uint8_t breathing_brightness(uint16_t phase)
{
/* 三角波亮度不需要查表,且在最暗处保留最低可见亮度。 */
uint16_t wave = phase & 0x01FFU;
if (wave > 255U) {
wave = 511U - wave;
@@ -175,6 +185,7 @@ static void fill_breathing_pixels(uint8_t *pixels, size_t pixel_len, uint8_t hue
static void platform_pulse_task(void *arg)
{
/* 独立任务产生固定占空节奏,不阻塞灯带 RMT 刷新。 */
(void)arg;
while (true) {
@@ -187,6 +198,7 @@ static void platform_pulse_task(void *arg)
static void platform_rmt_led_task(void *arg)
{
/* 每帧等待上一次 RMT 发送完成,防止复用仍被 DMA/RMT 消费的像素缓冲。 */
(void)arg;
uint8_t pixels[PLATFORM_RMT_LED_COUNT * PLATFORM_RMT_LED_BYTES_PER_PIXEL] = {0};
@@ -264,6 +276,7 @@ static void platform_rmt_led_task(void *arg)
esp_err_t platform_io_effects_init(void)
{
/* 只有全部 GPIO/RMT 资源和任务创建成功后才保存任务句柄。 */
gpio_config_t pulse_config = {
.pin_bit_mask = BIT64(PLATFORM_PULSE_GPIO),
.mode = GPIO_MODE_OUTPUT,
@@ -35,12 +35,22 @@
#define PLATFORM_STEPPER_IN2_PIN CONFIG_TQ_STEPPER_IN2_GPIO
#define PLATFORM_STEPPER_IN3_PIN CONFIG_TQ_STEPPER_IN3_GPIO
#define PLATFORM_STEPPER_IN4_PIN CONFIG_TQ_STEPPER_IN4_GPIO
/*
* 2200us 是重新励磁和加速起点,较慢起步用于降低失步风险;加速/减速各48个半步,
* 短行程会按比例压缩。4128半步/圈包含电机步距、减速箱和八拍半步序列的综合标定,
* 修改电机或传动结构后必须重新实测标定。
*/
#define PLATFORM_STEPPER_START_DELAY_US 2200
#define PLATFORM_STEPPER_ACCEL_STEPS 48
#define PLATFORM_STEPPER_DECEL_STEPS 48
#define PLATFORM_STEPPER_STEPS_PER_REV 4128.0f
/*
* 释放保持电流最多等待1秒获取运动锁;单次旋转限制100度用于保护当前机械行程,
* 不是电机本体能力。扩大角度前必须确认实体限位和线缆余量。
*/
#define PLATFORM_STEPPER_POWER_OFF_WAIT_MS 1000
#define PLATFORM_STEPPER_MAX_ROTATION_DEG 100.0f
/* ESP32-S3 GPIO0-31 与 GPIO32以上使用不同输出寄存器组。 */
#define PLATFORM_GPIO_LOW_BANK_MAX_PIN 31
#define PLATFORM_GPIO_HIGH_BANK_OFFSET 32
@@ -53,6 +63,7 @@ static StaticSemaphore_t s_stepper_motion_mutex_buf;
static int s_stepper_phase_index;
static bool s_stepper_energized;
/* 八拍半步序列兼顾转动平滑度和低速扭矩,反转时按相反方向遍历。 */
static const int s_step_sequence[8][4] = {
{1, 0, 0, 0},
{1, 1, 0, 0},
@@ -91,6 +102,7 @@ static void add_gpio_level_mask(uint32_t *set0,
static void set_motor_pins(int in1, int in2, int in3, int in4)
{
/* 先计算寄存器掩码,再批量更新四相输出,缩短相位切换的时间偏差。 */
uint32_t set0 = 0;
uint32_t clr0 = 0;
uint32_t set1 = 0;
@@ -219,6 +231,7 @@ static void calculate_motion_segments(int steps,
int *out_constant_steps,
int *out_decel_steps)
{
/* 短行程按比例缩短加减速段,确保三段步数之和不超过总步数。 */
int accel_steps = PLATFORM_STEPPER_ACCEL_STEPS;
int decel_steps = PLATFORM_STEPPER_DECEL_STEPS;
@@ -278,6 +291,7 @@ static esp_err_t rotate_with_accel(int steps,
int timeout_ms,
int64_t start_ms)
{
/* 每步动态计算延时形成加速、匀速、减速曲线,并在步间检查总超时。 */
int accel_steps = 0;
int constant_steps = 0;
int decel_steps = 0;
@@ -340,6 +354,7 @@ esp_err_t platform_stepper_driver_rotate_angle_with_accel(float angle,
int target_delay_us,
int timeout_ms)
{
/* 角度先换算为整数半步;方向只决定相位遍历方向,不改变加速曲线。 */
if (!ensure_stepper_motion_mutex()) {
return ESP_ERR_INVALID_STATE;
}
@@ -406,6 +421,7 @@ esp_err_t platform_stepper_driver_rotate_angle_with_accel(float angle,
esp_err_t platform_stepper_driver_release_hold(int timeout_ms)
{
/* 与运动使用同一互斥锁,保证释放线圈不会发生在相位更新中间。 */
if (timeout_ms < 0) {
return ESP_ERR_INVALID_ARG;
}
@@ -431,5 +447,6 @@ esp_err_t platform_stepper_driver_release_hold(int timeout_ms)
void platform_stepper_driver_power_off(void)
{
/* 紧急断电直接清零相线;常规并发路径应优先使用 release_hold。 */
(void)platform_stepper_driver_release_hold(PLATFORM_STEPPER_POWER_OFF_WAIT_MS);
}
@@ -26,6 +26,7 @@ static SemaphoreHandle_t s_wifi_behavior_hooks_mutex;
static StaticSemaphore_t s_wifi_behavior_hooks_mutex_buf;
#endif
/* 钩子注册可能与 Wi-Fi 事件并发发生,因此使用独立互斥锁保护整组快照。 */
static bool ensure_wifi_behavior_hooks_mutex(void)
{
if (s_wifi_behavior_hooks_mutex != NULL) {
@@ -61,6 +62,7 @@ static void unlock_wifi_behavior_hooks(void)
static void platform_wifi_behavior_set_keep_awake_adapter(bool keep_awake)
{
/* 在锁内复制、锁外调用,防止外部回调反向注册钩子时死锁。 */
platform_wifi_behavior_hooks_t hooks = {0};
if (lock_wifi_behavior_hooks()) {
hooks = s_wifi_behavior_hooks;
@@ -98,6 +100,7 @@ esp_err_t platform_behavior_play_prompt_file(const char *path)
void platform_wifi_portal_set_behavior_hooks(const platform_wifi_behavior_hooks_t *hooks)
{
/* NULL 表示清空全部自定义行为并恢复“不支持”的默认结果。 */
if (!lock_wifi_behavior_hooks()) {
return;
}
@@ -146,6 +149,7 @@ bool platform_wifi_portal_sta_is_connected(void)
esp_err_t platform_wifi_portal_set_light_standby(bool standby)
{
/* 轻待机只调整 modem 省电策略,不停止 STA 或清除已保存凭据。 */
wifi_ps_type_t ps_type = standby ? WIFI_PS_MIN_MODEM :
(CONFIG_TQ_WIFI_STA_POWER_SAVE ? WIFI_PS_MIN_MODEM : WIFI_PS_NONE);
return esp_wifi_set_ps(ps_type);
@@ -27,6 +27,11 @@ static const char *TAG = "platform_ble_gatt";
#include "services/gap/ble_svc_gap.h"
#include "services/gatt/ble_svc_gatt.h"
/*
* 启动最多等待3秒让 NimBLE host 同步;首选 MTU 256在通知吞吐和 mbuf 占用之间折中。
* 停止由独立任务最多尝试3次、间隔100ms,防止 host 瞬时忙状态导致资源遗留。
* ESP-IDF 的 FreeRTOS 创建接口按字节解释这里的4096栈配置,名称保留历史叫法。
*/
#define PLATFORM_BLE_SYNC_TIMEOUT_MS 3000
#define PLATFORM_BLE_PREFERRED_MTU 256
#define PLATFORM_BLE_STOP_DONE_BIT BIT0
@@ -35,6 +40,7 @@ static const char *TAG = "platform_ble_gatt";
#define PLATFORM_BLE_STOP_MAX_ATTEMPTS 3
#define PLATFORM_BLE_STOP_RETRY_DELAY_MS 100
/* BLE 生命周期状态由自旋锁保护;NimBLE 回调只读取必要快照后再调用外部代码。 */
typedef struct {
bool started;
bool synced;
@@ -76,6 +82,7 @@ static int platform_ble_gatt_access(uint16_t connection_handle,
static int platform_ble_gap_event(struct ble_gap_event *event, void *arg);
static const struct ble_gatt_svc_def s_gatt_services[] = {
/* 单服务包含写入、通知以及可读通知三个通用特征值。 */
{
.type = BLE_GATT_SVC_TYPE_PRIMARY,
.uuid = &s_service_uuid.u,
@@ -107,6 +114,7 @@ static const struct ble_gatt_svc_def s_gatt_services[] = {
static void platform_ble_set_uuid(ble_uuid128_t *out,
const platform_ble_uuid128_t *source)
{
/* 写入 payload 在回调返回后失效;业务层需要异步使用时必须自行复制。 */
memset(out, 0, sizeof(*out));
out->u.type = BLE_UUID_TYPE_128;
memcpy(out->value, source->bytes, sizeof(out->value));
@@ -129,6 +137,7 @@ static esp_err_t platform_ble_validate_config(
static void platform_ble_clear_runtime_state(void)
{
/* 停止完成后清除连接句柄和回调,防止下次启动复用旧会话状态。 */
portENTER_CRITICAL(&s_state_lock);
s_state.started = false;
s_state.synced = false;
@@ -145,6 +154,7 @@ static void platform_ble_clear_runtime_state(void)
static esp_err_t platform_ble_start_advertising(void)
{
/* 主机同步完成后才开始广播;断连后由 GAP 事件路径重新广播。 */
struct ble_hs_adv_fields fields = {0};
struct ble_gap_adv_params parameters = {0};
char advertised_name[PLATFORM_BLE_GATT_NAME_MAX_LEN + 1];
@@ -265,6 +275,7 @@ static int platform_ble_gatt_access(uint16_t connection_handle,
static int platform_ble_gap_event(struct ble_gap_event *event, void *arg)
{
/* GAP 回调只维护连接/订阅快照,断连后恢复广播以支持重新发现。 */
(void)arg;
switch (event->type) {
@@ -328,6 +339,7 @@ esp_err_t platform_ble_gatt_server_notify(platform_ble_gatt_notify_target_t targ
const uint8_t *data,
size_t data_len)
{
/* 通知前原子快照连接句柄和订阅状态,未订阅不会分配 mbuf。 */
if (data == NULL || data_len == 0U ||
(target != PLATFORM_BLE_GATT_NOTIFY_VALUE &&
target != PLATFORM_BLE_GATT_READ_NOTIFY_VALUE)) {
@@ -374,6 +386,7 @@ esp_err_t platform_ble_gatt_server_notify(platform_ble_gatt_notify_target_t targ
static void platform_ble_stop_task(void *arg)
{
/* NimBLE stop 可能需要多次尝试;后台任务负责重试并发布唯一完成结果。 */
(void)arg;
bool host_stopped = false;
portENTER_CRITICAL(&s_state_lock);
@@ -444,6 +457,7 @@ static void platform_ble_stop_task(void *arg)
esp_err_t platform_ble_gatt_server_start(const platform_ble_gatt_server_config_t *config)
{
/* 配置数据和回调先复制入内部状态,再启动 NimBLE host 并等待同步完成。 */
esp_err_t err = platform_ble_validate_config(config);
if (err != ESP_OK) {
return err;
@@ -534,6 +548,7 @@ esp_err_t platform_ble_gatt_server_start(const platform_ble_gatt_server_config_t
esp_err_t platform_ble_gatt_server_stop(int timeout_ms)
{
/* 零超时只触发后台停止;正超时等待事件位,不在调用线程忙等。 */
if (timeout_ms < 0) {
return ESP_ERR_INVALID_ARG;
}
@@ -48,6 +48,7 @@ static const char *const s_known_nvs_namespaces[] = {
"fortune",
};
/* 深睡唤醒原因在启动早期读取,供组合层决定是否恢复外设保持状态。 */
bool platform_power_woke_from_deep_sleep(void)
{
return esp_sleep_get_wakeup_cause() != ESP_SLEEP_WAKEUP_UNDEFINED;
@@ -66,6 +67,7 @@ static void platform_power_set_output_low_and_hold(int gpio)
static void platform_power_set_board_outputs_held(bool held)
{
/* 深睡前统一拉低高耗电输出,避免外设在主 CPU 休眠后继续耗电。 */
const int output_gpios[] = {
CONFIG_MULTIMODAL_AUDIO_PA_GPIO,
CONFIG_TQ_SCREEN_BACKLIGHT_GPIO,
@@ -99,6 +101,7 @@ void platform_power_release_deep_sleep_holds(void)
esp_err_t platform_power_enter_deep_sleep(int charger_gpio, int button_gpio)
{
/* 两个唤醒源均按低电平触发;无效 GPIO 必须在进入睡眠前被拒绝。 */
uint64_t wake_mask = 0U;
const int wake_gpios[] = {charger_gpio, button_gpio};
@@ -150,6 +153,7 @@ esp_err_t platform_power_enter_deep_sleep(int charger_gpio, int button_gpio)
static esp_err_t platform_gpio_isr_service_init(void)
{
/* 全局 ISR service 只安装一次,ESP_ERR_INVALID_STATE 表示已有合法所有者。 */
if (s_gpio_isr_service_installed) {
return ESP_OK;
}
@@ -184,6 +188,7 @@ esp_err_t platform_read_firmware_version(char *out, size_t out_len)
static void platform_log_nvs_stats(void)
{
/* 仅输出容量诊断,不读取键值内容,避免日志泄露凭据。 */
nvs_stats_t stats = {0};
esp_err_t err = nvs_get_stats(NULL, &stats);
if (err != ESP_OK) {
@@ -265,6 +270,7 @@ static bool platform_read_nvs_string(const char *ns, const char *key, char *out,
esp_err_t platform_init(void)
{
/* 该入口只初始化平台基础设施;存储准备保留为独立、可重试的阶段。 */
if (s_initialized) {
return ESP_OK;
}
@@ -299,6 +305,7 @@ esp_err_t platform_init(void)
esp_err_t platform_prepare_storage(void)
{
/* NVS 页损坏或版本不匹配时按 ESP-IDF 建议擦除后重新初始化。 */
if (s_storage_ready) {
return ESP_OK;
}
@@ -330,6 +337,7 @@ void platform_read_device_identity(char *device_id,
char *serial_number,
size_t serial_number_len)
{
/* 每个输出缓冲先清空;NVS 缺失时使用芯片派生默认值而非旧数据。 */
if (device_id != NULL && device_id_len > 0U) {
device_id[0] = '\0';
(void)platform_read_nvs_string(
@@ -351,6 +359,7 @@ void platform_read_device_identity(char *device_id,
esp_err_t platform_write_device_identity(const char *device_id, const char *serial_number)
{
/* 两个字段在同一 NVS 句柄中写入并一次 commit,避免只持久化一半身份。 */
if (device_id == NULL || serial_number == NULL ||
device_id[0] == '\0' || serial_number[0] == '\0') {
return ESP_ERR_INVALID_ARG;
@@ -406,6 +406,7 @@ static const char *dns_error_name(int err)
static esp_err_t parse_url_host(const char *url, char *host, size_t host_capacity)
{
/* 仅提取 DNS 主机部分,同时兼容带方括号的 IPv6 字面量。 */
if (url == NULL || host == NULL || host_capacity == 0U) {
return ESP_ERR_INVALID_ARG;
}
@@ -446,6 +447,7 @@ static esp_err_t parse_url_host(const char *url, char *host, size_t host_capacit
static esp_err_t resolve_host_before_open(const char *url)
{
/* 在创建 HTTP client 前显式解析 DNS,便于提供稳定错误码和网络诊断。 */
char host[PLATFORM_HTTPS_HOST_MAX_LEN];
esp_err_t err = parse_url_host(url, host, sizeof(host));
if (err != ESP_OK) {
@@ -528,6 +530,7 @@ static esp_err_t resolve_host_before_open(const char *url)
static esp_err_t write_all(esp_http_client_handle_t client, const char *payload)
{
/* esp_http_client_write 允许短写,循环直到完整 payload 发出或明确失败。 */
size_t payload_len = strlen(payload);
if (payload_len > (size_t)INT_MAX) {
return ESP_ERR_INVALID_SIZE;
@@ -551,6 +554,7 @@ static esp_err_t write_all(esp_http_client_handle_t client, const char *payload)
esp_err_t platform_https_post_json(const platform_https_post_json_request_t *request,
platform_https_response_t *response)
{
/* 先清空调用方响应快照,确保任何提前失败都不会暴露上次请求的旧数据。 */
if (response != NULL) {
response->body_len = 0U;
response->status_code = -1;
@@ -24,12 +24,20 @@
#include "mdns.h"
#include "soc/soc_caps.h"
/*
* 单任务串行处理 HTTP,8KB 栈覆盖解析及业务 handler 调用。2KB 头部上限和公共 body
* 上限共同决定固定 I/O 缓冲大小;提高上限会增加服务器整个生命周期的常驻内存。
*/
#define LAN_HTTP_TASK_STACK_SIZE (1024 * 8)
#define LAN_HTTP_TASK_PRIORITY 4
#define LAN_HTTP_HEADER_MAX_LEN 2048
#define LAN_HTTP_REQUEST_BUFFER_BYTES (LAN_HTTP_HEADER_MAX_LEN + \
PLATFORM_LAN_HTTP_REQUEST_BODY_MAX_LEN + 1U)
#define LAN_HTTP_RESPONSE_BUFFER_BYTES PLATFORM_LAN_HTTP_RESPONSE_BODY_MAX_LEN
/*
* 3秒是每个 client socket 的收发超时;select 每200ms醒来检查停止请求;启动最多
* 等待1.5秒获得 bind/listen 结果;停止轮询20ms用于兼顾响应速度和调度开销。
*/
#define LAN_HTTP_IO_TIMEOUT_MS 3000
#define LAN_HTTP_SELECT_INTERVAL_MS 200
#define LAN_HTTP_START_TIMEOUT_MS 1500
@@ -68,6 +76,7 @@ static uint8_t *s_response_buffer;
static esp_err_t s_start_result = ESP_ERR_INVALID_STATE;
static portMUX_TYPE s_server_lock = portMUX_INITIALIZER_UNLOCKED;
/* 服务器状态和 socket 句柄由同一自旋锁保护,耗时 I/O 始终在锁外执行。 */
static void server_task(void *arg);
static void lan_http_log_heap_snapshot(const char *phase)
@@ -98,6 +107,7 @@ static void lan_http_log_heap_snapshot(const char *phase)
static void *lan_http_alloc_io_buffer(size_t size)
{
/* 大块收发缓冲优先放入外部内存,保留内部 RAM 给网络栈和实时任务。 */
if (size == 0U) {
return NULL;
}
@@ -246,6 +256,7 @@ static const char *http_content_type(platform_lan_http_content_type_t content_ty
static esp_err_t send_all(int fd, const void *data, size_t len)
{
/* TCP send 可能短写或被信号中断,循环到完整发送或出现确定错误。 */
const uint8_t *cursor = (const uint8_t *)data;
size_t sent = 0;
@@ -307,6 +318,7 @@ static void send_error_response(int fd,
static const char *find_header_value(const char *headers, const char *name)
{
/* 只在已终止的头部缓冲内逐行搜索,返回值指向请求缓冲且不单独分配。 */
size_t name_len = strlen(name);
const char *line = strstr(headers, "\r\n");
if (line == NULL) {
@@ -357,6 +369,7 @@ static esp_err_t read_request(int fd,
platform_lan_http_request_t *out_request,
platform_lan_http_status_t *out_error_status)
{
/* 先在固定上限内读取完整头,再依据 Content-Length 读取有界 body。 */
if (request_buffer == NULL || request_buffer_len < LAN_HTTP_REQUEST_BUFFER_BYTES) {
*out_error_status = PLATFORM_LAN_HTTP_STATUS_INTERNAL_ERROR;
return ESP_ERR_INVALID_ARG;
@@ -427,6 +440,7 @@ static esp_err_t read_request(int fd,
strlcpy(out_request->path, target, sizeof(out_request->path));
if (find_header_value((char *)request_buffer, "Transfer-Encoding") != NULL) {
/* 简化服务器不支持 chunked 编码,明确拒绝以避免请求边界歧义。 */
*out_error_status = PLATFORM_LAN_HTTP_STATUS_BAD_REQUEST;
return ESP_FAIL;
}
@@ -465,6 +479,7 @@ static esp_err_t read_request(int fd,
static platform_lan_http_handler_t snapshot_handler(void **out_user_data)
{
/* handler 与 user_data 作为一组快照读取,业务回调执行期间不持有状态锁。 */
platform_lan_http_handler_t handler;
portENTER_CRITICAL(&s_server_lock);
handler = s_config.handler;
@@ -479,6 +494,7 @@ static void handle_client(int fd,
uint8_t *response_buffer,
size_t response_buffer_len)
{
/* 单连接串行处理,request/response 都借用服务器任务拥有的固定缓冲区。 */
platform_lan_http_request_t request;
platform_lan_http_status_t error_status = PLATFORM_LAN_HTTP_STATUS_BAD_REQUEST;
if (read_request(fd,
@@ -593,6 +609,7 @@ static void log_server_address(uint16_t port, const char *hostname)
static void server_task(void *arg)
{
/* 工作任务独占 listen/client socket 和 I/O 缓冲,所有退出路径统一释放。 */
(void)arg;
platform_lan_http_server_config_t config;
@@ -651,6 +668,7 @@ static void server_task(void *arg)
log_server_address(config.port, config.hostname);
while (!server_stop_requested()) {
/* select 使用短周期轮询停止标志,同时避免永久阻塞在 accept。 */
fd_set read_fds;
FD_ZERO(&read_fds);
FD_SET(listen_fd, &read_fds);
@@ -728,6 +746,7 @@ startup_failed:
esp_err_t platform_lan_http_server_start(const platform_lan_http_server_config_t *config)
{
/* 配置按值复制;启动调用只等待工作任务报告 bind/listen 结果。 */
if (config == NULL ||
config->handler == NULL ||
config->port == 0U ||
@@ -776,6 +795,7 @@ esp_err_t platform_lan_http_server_start(const platform_lan_http_server_config_t
esp_err_t platform_lan_http_server_stop(int timeout_ms)
{
/* shutdown 主动唤醒阻塞 I/O,再等待任务自行清理;超时后确定性强制删除。 */
if (timeout_ms < 0) {
return ESP_ERR_INVALID_ARG;
}
@@ -7,6 +7,10 @@
#include "platform.h"
#include "lcd_direct.h"
/*
* facade 适配层刻意不保存状态:LCD 生命周期、队列和完成计数均以
* lcd_direct 为唯一事实来源,避免 platform API 与驱动内部状态分叉。
*/
esp_err_t platform_init_lcd_device(void)
{
return lcd_direct_init_device();
@@ -21,6 +21,7 @@ static portMUX_TYPE s_multimodal_downstream_playback_cb_lock = portMUX_INITIALIZ
static uint32_t s_battery_playback_generation;
static bool s_battery_playback_active;
/* 将扬声器播放纳入电池高负载窗口,避免负载压降被误判为真实低电量。 */
static void platform_battery_note_playback_event(
multimodal_audio_downstream_playback_event_type_t type,
uint32_t generation)
@@ -61,11 +62,13 @@ static void snapshot_transport_callbacks(
portENTER_CRITICAL(&s_multimodal_transport_cb_lock);
*out_callbacks = s_multimodal_transport_callbacks;
portEXIT_CRITICAL(&s_multimodal_transport_cb_lock);
/* 回调快照离开临界区后再调用,防止用户代码在自旋锁内运行。 */
}
static void platform_multimodal_transport_event_bridge(const multimodal_ws_event_t *event,
void *user_data)
{
/* 此处只做底层事件形状转换,连接策略和业务语义由上层负责。 */
(void)user_data;
if (event == NULL) {
@@ -19,12 +19,18 @@
#include "esp_timer.h"
#include "mbedtls/sha256.h"
/*
* 4KB 下载缓冲在 TLS/HTTP 内存和 Flash 写入吞吐之间折中;最多5次重定向用于兼容
* CDN,同时限制异常跳转链。10秒仅限制单次网络 I/O,整个 OTA 仍共享调用方提供的
* 绝对 timeout_ms 预算。
*/
#define PLATFORM_OTA_DOWNLOAD_BUFFER_SIZE 4096
#define PLATFORM_OTA_MAX_REDIRECTS 5
#define PLATFORM_OTA_IO_TIMEOUT_MS 10000
static const char *TAG = "platform_ota";
/* OTA 使用单一绝对截止时间,DNS、HTTP 和写 Flash 都共享同一超时预算。 */
static bool platform_ota_deadline_expired(int64_t deadline_us)
{
return esp_timer_get_time() >= deadline_us;
@@ -49,6 +55,7 @@ esp_err_t platform_ota_install_image(const char *url,
platform_ota_cancel_callback_t cancel_cb,
void *user_data)
{
/* 仅接受 HTTPS,并在下载前确认目标镜像能够放入非活动分区。 */
if (url == NULL || strncmp(url, "https://", 8) != 0 ||
expected_sha256 == NULL || expected_size == 0U || timeout_ms <= 0) {
return ESP_ERR_INVALID_ARG;
@@ -106,6 +113,7 @@ esp_err_t platform_ota_install_image(const char *url,
esp_http_client_cleanup(client);
return ESP_ERR_INVALID_RESPONSE;
}
/* OTA 只接受完整 200 响应,不处理 range 或服务端错误页。 */
int status_code = esp_http_client_get_status_code(client);
if (status_code != 200) {
ESP_LOGW(TAG, "OTA download returned HTTP %d", status_code);
@@ -113,6 +121,7 @@ esp_err_t platform_ota_install_image(const char *url,
esp_http_client_cleanup(client);
return ESP_ERR_INVALID_RESPONSE;
}
/* 服务端提供长度时先行拒绝不匹配镜像,避免无意义地擦写分区。 */
if (content_length >= 0 && (uint64_t)content_length != expected_size) {
ESP_LOGE(TAG,
"OTA Content-Length mismatch: expected=%" PRIu64 " actual=%" PRIi64,
@@ -147,6 +156,7 @@ esp_err_t platform_ota_install_image(const char *url,
}
uint64_t received = 0U;
/* 每轮重新计算剩余预算,使网络读取、Flash 写入和取消检查共享总超时。 */
while (received < expected_size) {
bool cancelled = cancel_cb != NULL && cancel_cb(user_data);
if (cancelled || platform_ota_deadline_expired(deadline_us)) {
@@ -223,6 +233,7 @@ esp_err_t platform_ota_install_image(const char *url,
}
mbedtls_sha256_free(&sha_ctx);
/* 哈希不匹配时不切换启动分区,当前固件仍保持可启动。 */
if (memcmp(actual_sha256, expected_sha256, sizeof(actual_sha256)) != 0) {
ESP_LOGE(TAG, "OTA SHA-256 mismatch");
err = ESP_ERR_INVALID_CRC;
@@ -260,6 +271,7 @@ cleanup_no_abort:
esp_err_t platform_ota_confirm_running_image(void)
{
/* 仅在镜像处于 pending verify 时取消回滚,重复确认视为成功。 */
const esp_partition_t *running = esp_ota_get_running_partition();
if (running == NULL) {
return ESP_ERR_NOT_FOUND;
@@ -12,6 +12,7 @@
static const char *TAG = "platform_storage";
/* 所有 KV 操作都先检查统一的存储就绪状态,避免各调用方重复维护状态。 */
static esp_err_t ensure_storage_ready(const char *op)
{
if (platform_storage_is_ready()) {
@@ -40,11 +41,13 @@ static esp_err_t open_storage_readonly(const char *ns, nvs_handle_t *out_handle)
static esp_err_t normalize_missing_key(esp_err_t err)
{
/* 对外统一使用 ESP_ERR_NOT_FOUND,屏蔽 NVS 内部错误码差异。 */
return (err == ESP_ERR_NVS_NOT_FOUND) ? ESP_ERR_NOT_FOUND : err;
}
esp_err_t platform_storage_set_blob(const char *ns, const char *key, const void *data, size_t len)
{
/* set 与 commit 在同一句柄生命周期内完成,只有提交成功才报告写入成功。 */
if (ns == NULL || key == NULL || data == NULL || len == 0U) {
return ESP_ERR_INVALID_ARG;
}
@@ -149,6 +152,7 @@ esp_err_t platform_storage_get_blob(const char *ns,
size_t out_len,
size_t *out_len_actual)
{
/* 先查询实际长度再读取,长度不足时返回标准错误并报告所需字节数。 */
if (out_len_actual != NULL) {
*out_len_actual = 0U;
}
@@ -260,6 +264,7 @@ esp_err_t platform_storage_get_string(const char *ns,
size_t out_len,
size_t *out_len_actual)
{
/* NVS 返回长度包含结尾 NUL;对外 actual 同样保留该约定,便于安全分配。 */
if (out_len_actual != NULL) {
*out_len_actual = 0U;
}
@@ -298,6 +303,7 @@ esp_err_t platform_storage_get_string(const char *ns,
esp_err_t platform_storage_erase(const char *ns, const char *key)
{
/* 删除不存在的键归一化为成功,使清理和迁移流程保持幂等。 */
if (ns == NULL || key == NULL) {
return ESP_ERR_INVALID_ARG;
}
@@ -23,6 +23,7 @@ static const char *TAG = "platform_time";
static bool s_sntp_started;
static portMUX_TYPE s_time_lock = portMUX_INITIALIZER_UNLOCKED;
/* POSIX TZ 影响整个进程,更新后调用 tzset 立即刷新 libc 的本地时间规则。 */
esp_err_t platform_time_set_timezone(const char *posix_tz)
{
if (posix_tz == NULL || posix_tz[0] == '\0') {
@@ -39,6 +40,7 @@ esp_err_t platform_time_set_timezone(const char *posix_tz)
esp_err_t platform_time_sync_start(const char *sntp_server, const char *posix_tz)
{
/* 重复调用只更新时区;SNTP 客户端只初始化一次,避免重复注册资源。 */
if (posix_tz != NULL && posix_tz[0] != '\0') {
esp_err_t timezone_err = platform_time_set_timezone(posix_tz);
if (timezone_err != ESP_OK) {
@@ -78,6 +80,7 @@ esp_err_t platform_time_sync_start(const char *sntp_server, const char *posix_tz
bool platform_time_is_synced(void)
{
/* 不复制 SNTP 内部状态,以系统时钟越过合理下界作为同步成功事实来源。 */
time_t now = 0;
time(&now);
return (int64_t)now >= PLATFORM_TIME_MIN_VALID_EPOCH_S;
@@ -25,6 +25,10 @@
static const char *TAG = "wifi_portal";
/*
* 门户锁最多等待2秒,防止第三方 manager 卡住所有调用者;默认连接预算10秒由多个
* 已保存候选共享,100ms 轮询在连接响应速度与任务唤醒开销之间折中。
*/
#define WIFI_PORTAL_LOCK_TIMEOUT_MS 2000
#define WIFI_PORTAL_DEFAULT_CONNECT_TIMEOUT_MS 10000
#define WIFI_PORTAL_CONNECT_POLL_MS 100
@@ -49,6 +53,7 @@ static wifi_portal_behavior_hooks_t s_behavior_hooks = {};
static esp_err_t apply_sta_stability_policy(void);
/* 将 wifi-manager 的凭据持久化重定向到平台 NVS,避免组件维护第二份存储。 */
static void install_wifi_storage_hooks(void)
{
static bool s_installed;
@@ -92,6 +97,7 @@ static void log_saved_wifi_candidates(const char *context)
}
struct PortalUnlockGuard {
/* RAII 保证每条提前返回路径都会释放门户互斥锁。 */
~PortalUnlockGuard()
{
if (active) {
@@ -127,6 +133,7 @@ static esp_err_t wifi_portal_lock(void)
static void promote_saved_wifi(const std::string &ssid)
{
/* 成功连接的 SSID 移到队首,使下次启动优先尝试最近可用网络。 */
if (ssid.empty()) {
return;
}
@@ -176,6 +183,7 @@ static void notify_keep_awake(bool keep_awake)
static void wifi_manager_event_callback(WifiEvent event, const std::string &data)
{
/* 事件回调只更新状态并转发通知,不在管理器线程里执行连接流程。 */
switch (event) {
case WifiEvent::Connecting:
ESP_LOGI(TAG, "Connecting to \"%s\"", data.c_str());
@@ -205,6 +213,7 @@ static void wifi_manager_event_callback(WifiEvent event, const std::string &data
static esp_err_t ensure_wifi_manager_initialized(void)
{
/* 管理器初始化和事件注册只执行一次,后续 API 共享同一实例。 */
install_wifi_storage_hooks();
if (s_wifi_manager_initialized) {
return ESP_OK;
@@ -254,6 +263,7 @@ static int normalize_connect_timeout_ms(int timeout_ms)
static esp_err_t apply_sta_stability_policy(void)
{
/* 关闭省电或调整失活时间,避免实时音频期间 Wi-Fi 周期性休眠。 */
wifi_ps_type_t ps_type = CONFIG_TQ_WIFI_STA_POWER_SAVE ?
WIFI_PS_MIN_MODEM :
WIFI_PS_NONE;
@@ -350,6 +360,7 @@ esp_err_t wifi_portal_start_config_ap(const char *ssid_prefix)
esp_err_t wifi_portal_connect_sta(int timeout_ms)
{
/* 在统一截止时间内按优先级尝试已保存网络,返回后不继续后台轮询。 */
{
ESP_RETURN_ON_ERROR(wifi_portal_lock(), TAG, "Failed to lock Wi-Fi portal");
PortalUnlockGuard guard;
@@ -393,6 +404,7 @@ esp_err_t wifi_portal_connect_sta_with_credentials(const char *ssid,
const char *password,
int timeout_ms)
{
/* 临时凭据仅用于本次连接;成功后再持久化,失败不污染候选列表。 */
ESP_RETURN_ON_FALSE(ssid != NULL && ssid[0] != '\0',
ESP_ERR_INVALID_ARG,
TAG,
@@ -516,6 +528,7 @@ esp_err_t wifi_portal_connect_sta_with_credentials(const char *ssid,
esp_err_t wifi_portal_stop_sta(void)
{
/* 停止 STA 时同步更新连接快照,确保查询接口不会返回陈旧状态。 */
ESP_RETURN_ON_ERROR(wifi_portal_lock(), TAG, "Failed to lock Wi-Fi portal");
PortalUnlockGuard guard;