feat(lcd): unify idle restoration and add clock battery HUD

This commit is contained in:
chenjintao
2026-10-09 14:38:17 +08:00
parent f6408be05b
commit e75846ea15
5 changed files with 294 additions and 46 deletions
@@ -1416,6 +1416,8 @@ esp_err_t domain_runtime_update_battery_overlay(const domain_battery_status_t *s
status->level_state == DOMAIN_BATTERY_LEVEL_CRITICAL), status->level_state == DOMAIN_BATTERY_LEVEL_CRITICAL),
.charging_visible = .charging_visible =
status->charge_state == DOMAIN_BATTERY_CHARGE_CHARGING, status->charge_state == DOMAIN_BATTERY_CHARGE_CHARGING,
.battery_valid = status->valid,
.battery_percent = status->percentage,
}; };
return platform_lcd_set_status_overlay(&overlay); return platform_lcd_set_status_overlay(&overlay);
} }
+2
View File
@@ -683,6 +683,8 @@ typedef struct {
typedef struct { typedef struct {
bool low_battery_visible; bool low_battery_visible;
bool charging_visible; bool charging_visible;
bool battery_valid; /* false 时显示未知值,不伪造 0%。 */
uint8_t battery_percent; /* domain 提供的 SOC,0..100。 */
} platform_lcd_status_overlay_t; } platform_lcd_status_overlay_t;
/* 状态覆盖层是持久状态,不会随一次 presentation 播放结束而自动清除。 */ /* 状态覆盖层是持久状态,不会随一次 presentation 播放结束而自动清除。 */
+178 -46
View File
@@ -12,6 +12,7 @@
#include <stdbool.h> #include <stdbool.h>
#include <stdint.h> #include <stdint.h>
#include <stdlib.h> #include <stdlib.h>
#include <stdio.h>
#include <string.h> #include <string.h>
#include <time.h> #include <time.h>
@@ -98,6 +99,7 @@
#define LCD_DIRECT_CLOCK_OUTLINE_COLOR 0x0000U #define LCD_DIRECT_CLOCK_OUTLINE_COLOR 0x0000U
#if CONFIG_TQ_LCD_EMOTE_GFX_TEST #if CONFIG_TQ_LCD_EMOTE_GFX_TEST
#define LCD_DIRECT_HUD_ROWS 40U
#define LCD_DIRECT_EMOTE_FPS 30U #define LCD_DIRECT_EMOTE_FPS 30U
#define LCD_DIRECT_EMOTE_BUFFER_ROWS 4U #define LCD_DIRECT_EMOTE_BUFFER_ROWS 4U
#define LCD_DIRECT_EMOTE_EYES_X 41 #define LCD_DIRECT_EMOTE_EYES_X 41
@@ -241,6 +243,12 @@ static gfx_image_dsc_t s_emote_eye_image;
static uint8_t *s_emote_base_pixels; static uint8_t *s_emote_base_pixels;
static uint8_t *s_emote_eye_pixels; static uint8_t *s_emote_eye_pixels;
static bool s_emote_ready; static bool s_emote_ready;
static gfx_obj_t *s_emote_hud;
static gfx_image_dsc_t s_emote_hud_image;
static uint16_t *s_emote_hud_pixels;
static char s_emote_hud_clock[LCD_DIRECT_CLOCK_TEXT_LENGTH + 1U];
static platform_lcd_status_overlay_t s_emote_hud_status;
static bool s_emote_hud_valid;
static gfx_handle_t s_emote_handle; static gfx_handle_t s_emote_handle;
static gfx_disp_t *s_emote_disp; static gfx_disp_t *s_emote_disp;
@@ -258,6 +266,18 @@ static void lcd_direct_overlay_clock(uint16_t *pixel_buffer,
uint16_t row_count, uint16_t row_count,
const char text[LCD_DIRECT_CLOCK_TEXT_LENGTH + 1U]); const char text[LCD_DIRECT_CLOCK_TEXT_LENGTH + 1U]);
static void lcd_direct_overlay_battery(uint16_t *pixels, uint16_t row_start, uint16_t rows);
static void lcd_direct_queue_idle_if_available(void)
{
/* 自动恢复不能覆盖动画结束瞬间到达的新请求。 */
portENTER_CRITICAL(&s_state_lock);
if (!s_pending_valid && !s_stop_requested) {
s_pending = (lcd_direct_request_t) { .kind = PLATFORM_LCD_PRESENTATION_IDLE };
s_pending_valid = true;
}
portEXIT_CRITICAL(&s_state_lock);
}
static bool IRAM_ATTR lcd_direct_on_color_trans_done(esp_lcd_panel_io_handle_t panel_io, static bool IRAM_ATTR lcd_direct_on_color_trans_done(esp_lcd_panel_io_handle_t panel_io,
esp_lcd_panel_io_event_data_t *event_data, esp_lcd_panel_io_event_data_t *event_data,
void *user_ctx) void *user_ctx)
@@ -423,8 +443,20 @@ static esp_err_t lcd_direct_emote_init(void)
ESP_RETURN_ON_ERROR(gfx_mesh_img_get_point_q8(s_emote_eyes_open, i, &s_emote_eye_rest[i]), ESP_RETURN_ON_ERROR(gfx_mesh_img_get_point_q8(s_emote_eyes_open, i, &s_emote_eye_rest[i]),
TAG, "read eye mesh failed"); TAG, "read eye mesh failed");
} }
ESP_RETURN_ON_ERROR(gfx_emote_lock(s_emote_handle), TAG, "lock esp_emote_gfx failed"); s_emote_hud_pixels = heap_caps_calloc(LCD_DIRECT_WIDTH * LCD_DIRECT_HUD_ROWS,
ESP_RETURN_ON_ERROR(gfx_emote_unlock(s_emote_handle), TAG, "unlock esp_emote_gfx failed"); sizeof(uint16_t), MALLOC_CAP_SPIRAM | MALLOC_CAP_8BIT);
ESP_RETURN_ON_FALSE(s_emote_hud_pixels != NULL, ESP_ERR_NO_MEM, TAG, "allocate HUD failed");
s_emote_hud_image = (gfx_image_dsc_t) {
.header.cf = GFX_COLOR_FORMAT_RGB565,
.header.magic = C_ARRAY_HEADER_MAGIC,
.header.w = LCD_DIRECT_WIDTH,
.header.h = LCD_DIRECT_HUD_ROWS,
.data_size = LCD_DIRECT_WIDTH * LCD_DIRECT_HUD_ROWS * sizeof(uint16_t),
.data = (const uint8_t *)s_emote_hud_pixels,
};
s_emote_hud = gfx_img_create(s_emote_disp);
ESP_RETURN_ON_FALSE(s_emote_hud != NULL, ESP_ERR_NO_MEM, TAG, "create HUD failed");
ESP_RETURN_ON_ERROR(gfx_img_set_src(s_emote_hud, &s_emote_hud_image), TAG, "set HUD failed");
s_emote_ready = true; s_emote_ready = true;
ESP_LOGI(TAG, ESP_LOGI(TAG,
"esp_emote_gfx idle test ready (%u fps, 2 x %u-row DMA buffers)", "esp_emote_gfx idle test ready (%u fps, 2 x %u-row DMA buffers)",
@@ -442,6 +474,47 @@ static bool lcd_direct_should_stop_idle(void)
return stop; return stop;
} }
/* 调用方持有 GFX 锁;字形复用 PNG 路径,无字体包和额外 PNG 解码。 */
static esp_err_t lcd_direct_emote_update_hud(bool force, bool *changed)
{
if (changed != NULL) {
*changed = false;
}
char clock[LCD_DIRECT_CLOCK_TEXT_LENGTH + 1U];
lcd_direct_clock_text(clock);
platform_lcd_status_overlay_t status;
portENTER_CRITICAL(&s_state_lock);
status = s_status_overlay;
portEXIT_CRITICAL(&s_state_lock);
if (!force && s_emote_hud_valid && strcmp(clock, s_emote_hud_clock) == 0 &&
status.battery_valid == s_emote_hud_status.battery_valid &&
status.battery_percent == s_emote_hud_status.battery_percent &&
status.low_battery_visible == s_emote_hud_status.low_battery_visible &&
status.charging_visible == s_emote_hud_status.charging_visible) {
return ESP_OK;
}
memset(s_emote_hud_pixels, 0, s_emote_hud_image.data_size);
lcd_direct_overlay_clock(s_emote_hud_pixels, 0, LCD_DIRECT_HUD_ROWS, clock);
lcd_direct_overlay_battery(s_emote_hud_pixels, 0, LCD_DIRECT_HUD_ROWS);
#if !CONFIG_TQ_SCREEN_SWAP_COLOR_BYTES
/* GFX 图像使用高字节在前,PNG 行缓冲可能使用主机字节序。 */
for (size_t i = 0; i < LCD_DIRECT_WIDTH * LCD_DIRECT_HUD_ROWS; ++i) {
uint16_t color = s_emote_hud_pixels[i];
s_emote_hud_pixels[i] = (color >> 8) | (color << 8);
}
#endif
esp_err_t err = gfx_img_set_src(s_emote_hud, &s_emote_hud_image);
if (err == ESP_OK) {
memcpy(s_emote_hud_clock, clock, sizeof(clock));
s_emote_hud_status = status;
s_emote_hud_valid = true;
if (changed != NULL) {
*changed = true;
}
}
return err;
}
static esp_err_t lcd_direct_emote_wait(uint32_t duration_ms) static esp_err_t lcd_direct_emote_wait(uint32_t duration_ms)
{ {
uint32_t remaining_ms = duration_ms; uint32_t remaining_ms = duration_ms;
@@ -449,6 +522,16 @@ static esp_err_t lcd_direct_emote_wait(uint32_t duration_ms)
if (lcd_direct_should_stop_idle()) { if (lcd_direct_should_stop_idle()) {
return ESP_ERR_INVALID_STATE; return ESP_ERR_INVALID_STATE;
} }
ESP_RETURN_ON_ERROR(gfx_emote_lock(s_emote_handle), TAG, "lock HUD failed");
bool changed = false;
esp_err_t hud_err = lcd_direct_emote_update_hud(false, &changed);
if (hud_err == ESP_OK && changed) {
hud_err = gfx_refr_now(s_emote_handle);
}
esp_err_t unlock_err = gfx_emote_unlock(s_emote_handle);
if (hud_err != ESP_OK || unlock_err != ESP_OK) {
return hud_err != ESP_OK ? hud_err : unlock_err;
}
uint32_t slice_ms = remaining_ms > LCD_DIRECT_PRESENTATION_WAIT_POLL_MS uint32_t slice_ms = remaining_ms > LCD_DIRECT_PRESENTATION_WAIT_POLL_MS
? LCD_DIRECT_PRESENTATION_WAIT_POLL_MS ? LCD_DIRECT_PRESENTATION_WAIT_POLL_MS
: remaining_ms; : remaining_ms;
@@ -495,7 +578,7 @@ static esp_err_t lcd_direct_emote_set_eye_closure(uint8_t closure)
LCD_DIRECT_EMOTE_EYES_Y + offset_y); LCD_DIRECT_EMOTE_EYES_Y + offset_y);
} }
static esp_err_t lcd_direct_play_emote_face_until_pending(void) static esp_err_t lcd_direct_play_emote_face_until_pending(const lcd_direct_request_t *request)
{ {
ESP_RETURN_ON_FALSE(s_emote_handle != NULL && s_emote_eyes_open != NULL, ESP_RETURN_ON_FALSE(s_emote_handle != NULL && s_emote_eyes_open != NULL,
ESP_ERR_INVALID_STATE, ESP_ERR_INVALID_STATE,
@@ -505,7 +588,10 @@ static esp_err_t lcd_direct_play_emote_face_until_pending(void)
portENTER_CRITICAL(&s_state_lock); portENTER_CRITICAL(&s_state_lock);
s_emote_active = true; s_emote_active = true;
portEXIT_CRITICAL(&s_state_lock); portEXIT_CRITICAL(&s_state_lock);
esp_err_t result = lcd_direct_emote_set_eye_closure(0U); esp_err_t result = lcd_direct_emote_update_hud(true, NULL);
if (result == ESP_OK) {
result = lcd_direct_emote_set_eye_closure(0U);
}
if (result == ESP_OK) { if (result == ESP_OK) {
gfx_disp_refresh_all(s_emote_disp); gfx_disp_refresh_all(s_emote_disp);
result = gfx_refr_now(s_emote_handle); result = gfx_refr_now(s_emote_handle);
@@ -521,8 +607,12 @@ static esp_err_t lcd_direct_play_emote_face_until_pending(void)
24U, 80U, 160U, 232U, 255U, 255U, 24U, 80U, 160U, 232U, 255U, 255U,
232U, 192U, 128U, 64U, 16U, 0U, 232U, 192U, 128U, 64U, 16U, 0U,
}; };
while (result == ESP_OK) { uint16_t cycles = 0;
result = lcd_direct_emote_wait(LCD_DIRECT_EMOTE_OPEN_MS); const uint16_t repeats = request != NULL ? request->repeat_count : 0;
const uint32_t open_ms = repeats > 0 && request->frame_ms > 0
? request->frame_ms : LCD_DIRECT_EMOTE_OPEN_MS;
while (result == ESP_OK && (repeats == 0 || cycles < repeats)) {
result = lcd_direct_emote_wait(open_ms);
if (result != ESP_OK) { if (result != ESP_OK) {
break; break;
} }
@@ -543,8 +633,12 @@ static esp_err_t lcd_direct_play_emote_face_until_pending(void)
result = lcd_direct_emote_wait(LCD_DIRECT_EMOTE_STEP_MS); result = lcd_direct_emote_wait(LCD_DIRECT_EMOTE_STEP_MS);
} }
} }
++cycles;
} }
if (result == ESP_OK && repeats > 0) {
lcd_direct_signal_completion();
}
esp_err_t lock_err = gfx_emote_lock(s_emote_handle); esp_err_t lock_err = gfx_emote_lock(s_emote_handle);
if (lock_err == ESP_OK) { if (lock_err == ESP_OK) {
portENTER_CRITICAL(&s_state_lock); portENTER_CRITICAL(&s_state_lock);
@@ -1117,13 +1211,7 @@ static size_t lcd_direct_append_status_icons(const lcd_direct_image_t *image,
.y = LCD_DIRECT_LOW_BATTERY_ICON_Y, .y = LCD_DIRECT_LOW_BATTERY_ICON_Y,
}; };
} }
if (overlay.charging_visible && icon_count < icon_capacity) { /* 充电状态改由右上角程序绘制的电池图标表示,避免和百分比重叠。 */
icons[icon_count++] = (lcd_direct_icon_draw_t) {
.icon_id = LCD_DIRECT_ICON_CHARGING,
.x = LCD_DIRECT_CHARGING_ICON_X,
.y = LCD_DIRECT_CHARGING_ICON_Y,
};
}
return icon_count; return icon_count;
} }
@@ -1291,6 +1379,59 @@ static void lcd_direct_overlay_clock(uint16_t *pixel_buffer,
LCD_DIRECT_CLOCK_COLOR); LCD_DIRECT_CLOCK_COLOR);
} }
/* 小型电池 + 数字百分比,沿用 5x7 点阵;所有写入都裁剪到当前行块。 */
static void lcd_direct_hud_pixel(uint16_t *pixels, uint16_t row_start,
uint16_t rows, int x, int y, uint16_t color)
{
if (x >= 0 && x < LCD_DIRECT_WIDTH && y >= row_start && y < row_start + rows) {
pixels[(y - row_start) * LCD_DIRECT_WIDTH + x] = lcd_direct_swap_color(color);
}
}
static void lcd_direct_overlay_battery(uint16_t *pixels, uint16_t row_start, uint16_t rows)
{
platform_lcd_status_overlay_t status;
portENTER_CRITICAL(&s_state_lock);
status = s_status_overlay;
portEXIT_CRITICAL(&s_state_lock);
const uint16_t color = status.charging_visible ? 0x07E0U :
status.low_battery_visible ? 0xF800U : 0xFFFFU;
const int left = LCD_DIRECT_WIDTH - 108;
char text[5];
if (status.battery_valid) {
snprintf(text, sizeof(text), "%u%%", (unsigned)status.battery_percent);
} else {
memcpy(text, "--%", 4);
}
static const uint8_t percent[7] = {0x19, 0x1A, 0x02, 0x04, 0x08, 0x0B, 0x13};
for (size_t i = 0; text[i] != '\0'; ++i) {
const uint8_t *glyph = text[i] == '%' ? percent :
s_clock_glyph_rows[lcd_direct_clock_glyph_index(text[i])];
for (int y = 0; y < 21; ++y) {
for (int x = 0; x < 15; ++x) {
if (glyph[y / 3] & (1U << (4 - x / 3))) {
lcd_direct_hud_pixel(pixels, row_start, rows, left + i * 18 + x, 13 + y, color);
}
}
}
}
const int icon_x = LCD_DIRECT_WIDTH - 32;
const int fill = status.battery_valid ? status.battery_percent * 16 / 100 : 0;
for (int y = 0; y < 20; ++y) {
for (int x = 0; x < 24; ++x) {
bool on = x < 22 && (x < 2 || x >= 20 || y < 2 || y >= 18);
on |= x >= 22 && y >= 6 && y < 14;
on |= status.battery_valid && x >= 3 && x < 3 + fill && y >= 3 && y < 17;
/* 充电时在电池中挖出黑色闪电,绿色外框仍可辨识。 */
if (status.charging_visible && y >= 3 && y < 17 &&
x >= 11 - (y - 3) / 3 && x <= 14 - (y - 3) / 3) {
on = false;
}
lcd_direct_hud_pixel(pixels, row_start, rows, icon_x + x, 14 + y, on ? color : 0);
}
}
}
static esp_err_t lcd_direct_draw_png_image(const lcd_direct_image_t *image, static esp_err_t lcd_direct_draw_png_image(const lcd_direct_image_t *image,
const lcd_direct_icon_draw_t *icons, const lcd_direct_icon_draw_t *icons,
size_t icon_count) size_t icon_count)
@@ -1367,6 +1508,9 @@ static esp_err_t lcd_direct_draw_png_image(const lcd_direct_image_t *image,
lcd_direct_overlay_clock(s_line_buffer, row_start, row_count, clock_text); lcd_direct_overlay_clock(s_line_buffer, row_start, row_count, clock_text);
} }
lcd_direct_overlay_icons(s_line_buffer, row_start, row_count, prepared_icons, prepared_icon_count); lcd_direct_overlay_icons(s_line_buffer, row_start, row_count, prepared_icons, prepared_icon_count);
if (clock_visible) {
lcd_direct_overlay_battery(s_line_buffer, row_start, row_count);
}
err = lcd_direct_draw_rows(row_start, row_count); err = lcd_direct_draw_rows(row_start, row_count);
if (err != ESP_OK) { if (err != ESP_OK) {
ESP_LOGE(TAG, "write PNG image pixels failed path=%s", image->path); ESP_LOGE(TAG, "write PNG image pixels failed path=%s", image->path);
@@ -1772,23 +1916,23 @@ static esp_err_t lcd_direct_play_asset_sequence(const lcd_direct_request_t *requ
TAG, TAG,
"invalid asset sequence"); "invalid asset sequence");
#if CONFIG_TQ_LCD_EMOTE_GFX_TEST
/* 兼容历史基础表情请求(包括服务器错误的有限播放),统一到新眼睛。 */
if (s_emote_ready && request->duration_ms == 0U &&
strcmp(request->asset_dir, "jichubiaoqing") == 0) {
return lcd_direct_play_emote_face_until_pending(request);
}
#endif
if (request->asset_dir[0] == '\0' || if (request->asset_dir[0] == '\0' ||
lcd_direct_asset_frame(request->asset_dir, lcd_direct_asset_frame(request->asset_dir,
request->frame_order_count > 0U ? request->frame_order[0] : 0U) == NULL) { request->frame_order_count > 0U ? request->frame_order[0] : 0U) == NULL) {
ESP_LOGW(TAG, "LCD asset sequence unavailable, fallback to basic face asset=%s", request->asset_dir); ESP_LOGW(TAG, "LCD asset unavailable, restore idle asset=%s", request->asset_dir);
const lcd_direct_request_t fallback = { lcd_direct_queue_idle_if_available();
.kind = PLATFORM_LCD_PRESENTATION_ASSET_SEQUENCE, if (request->repeat_count > 0U || request->duration_ms > 0U) {
.asset_dir = "jichubiaoqing",
.frame_order = {0, 1, 2},
.frame_order_count = 3,
.repeat_count = 1,
.frame_ms = 100,
};
esp_err_t fallback_err = lcd_direct_draw_asset_sequence_once(&fallback);
if (fallback_err == ESP_OK && request->repeat_count > 0U) {
lcd_direct_signal_completion(); lcd_direct_signal_completion();
} }
return fallback_err; return ESP_OK;
} }
if (request->duration_ms > 0U) { if (request->duration_ms > 0U) {
@@ -1878,7 +2022,7 @@ static esp_err_t lcd_direct_play_basic_face_until_pending(void)
{ {
#if CONFIG_TQ_LCD_EMOTE_GFX_TEST #if CONFIG_TQ_LCD_EMOTE_GFX_TEST
if (s_emote_ready) { if (s_emote_ready) {
return lcd_direct_play_emote_face_until_pending(); return lcd_direct_play_emote_face_until_pending(NULL);
} }
#endif #endif
const lcd_direct_request_t request = { const lcd_direct_request_t request = {
@@ -2083,29 +2227,15 @@ void lcd_direct_start_render_loop(void)
draw_err = lcd_direct_play_basic_face_until_pending(); draw_err = lcd_direct_play_basic_face_until_pending();
} else if (request.kind == PLATFORM_LCD_PRESENTATION_ASSET_SEQUENCE) { } else if (request.kind == PLATFORM_LCD_PRESENTATION_ASSET_SEQUENCE) {
draw_err = lcd_direct_play_asset_sequence(&request); draw_err = lcd_direct_play_asset_sequence(&request);
if (draw_err == ESP_OK && request.return_to_idle) {
lcd_direct_request_t idle = {
.kind = PLATFORM_LCD_PRESENTATION_IDLE,
};
portENTER_CRITICAL(&s_state_lock);
s_pending = idle;
s_pending_valid = true;
portEXIT_CRITICAL(&s_state_lock);
}
} else if (request.kind == PLATFORM_LCD_PRESENTATION_STEP_SEQUENCE) { } else if (request.kind == PLATFORM_LCD_PRESENTATION_STEP_SEQUENCE) {
draw_err = lcd_direct_play_step_sequence(&request); draw_err = lcd_direct_play_step_sequence(&request);
if (draw_err == ESP_OK && request.return_to_idle) {
lcd_direct_request_t idle = {
.kind = PLATFORM_LCD_PRESENTATION_IDLE,
};
portENTER_CRITICAL(&s_state_lock);
s_pending = idle;
s_pending_valid = true;
portEXIT_CRITICAL(&s_state_lock);
}
} else { } else {
draw_err = lcd_direct_draw_test_pattern(&request); draw_err = lcd_direct_draw_test_pattern(&request);
} }
if ((draw_err == ESP_OK && request.return_to_idle) ||
(draw_err != ESP_OK && draw_err != ESP_ERR_INVALID_STATE)) {
lcd_direct_queue_idle_if_available();
}
xSemaphoreGive(s_lock); xSemaphoreGive(s_lock);
if (draw_err != ESP_OK && draw_err != ESP_ERR_INVALID_STATE) { if (draw_err != ESP_OK && draw_err != ESP_ERR_INVALID_STATE) {
ESP_LOGW(TAG, "LCD presentation draw failed: %s", esp_err_to_name(draw_err)); ESP_LOGW(TAG, "LCD presentation draw failed: %s", esp_err_to_name(draw_err));
@@ -2187,7 +2317,7 @@ esp_err_t lcd_direct_stop_render_loop(int timeout_ms)
esp_err_t lcd_direct_set_status_overlay(const platform_lcd_status_overlay_t *overlay) esp_err_t lcd_direct_set_status_overlay(const platform_lcd_status_overlay_t *overlay)
{ {
if (overlay == NULL) { if (overlay == NULL || overlay->battery_percent > 100U) {
return ESP_ERR_INVALID_ARG; return ESP_ERR_INVALID_ARG;
} }
@@ -2195,7 +2325,9 @@ esp_err_t lcd_direct_set_status_overlay(const platform_lcd_status_overlay_t *ove
TaskHandle_t render_task = NULL; TaskHandle_t render_task = NULL;
portENTER_CRITICAL(&s_state_lock); portENTER_CRITICAL(&s_state_lock);
changed = s_status_overlay.low_battery_visible != overlay->low_battery_visible || changed = s_status_overlay.low_battery_visible != overlay->low_battery_visible ||
s_status_overlay.charging_visible != overlay->charging_visible; s_status_overlay.charging_visible != overlay->charging_visible ||
s_status_overlay.battery_valid != overlay->battery_valid ||
s_status_overlay.battery_percent != overlay->battery_percent;
s_status_overlay = *overlay; s_status_overlay = *overlay;
render_task = s_render_task; render_task = s_render_task;
portEXIT_CRITICAL(&s_state_lock); portEXIT_CRITICAL(&s_state_lock);
+44
View File
@@ -0,0 +1,44 @@
# 待机恢复与时间 / 电量状态栏
当前实现(2026-10-09)以本文为准,旧 lcd-expressions.md 中的测试图案、
优先级仲裁描述不代表当前运行行为。
## 返回待机
- 开机 / 默认空闲、显式 IDLE 请求、有限动画 return_to_idle、缺失素材回退和
非取消的播放错误都进入同一个待机后端。
- CONFIG_TQ_LCD_EMOTE_GFX_TEST 启用且初始化成功时使用单纹理网格眨眼;
禁用或初始化失败才使用历史三帧 PNG 安全后备。
- 历史 jichubiaoqing 请求(duration_ms=0)也适配到 GFX。
有限播放保留 repeat_count,每轮为 frame_ms 睁眼停留 + 12 个 34ms 眨眼步骤;
不承诺与旧 PNG 完全相同的持续时间。服务错误因此不再临时退回三帧。
- 自动恢复只在没有 pending 请求且未停止时排入 IDLE,不能覆盖新来的请求。
- 其他表情仍为 PNG / step sequence,未改动其图像或动画顺序。
pending 仍是单槽替换,不是完整优先级状态机。
## 状态栏
- 左上 HH:MM,沿用 SNTP 和系统时区;未同步显示 --:--。
- 右上百分比和电池;未知值显示 --%,充电绿色并带闪电缺口,低电量红色。
- SOC、有效性和充电状态来自现有 domain battery 服务,不从 ADC 重算。
- GFX 顶部 320x40 RGB565 图层,常驻 25,600 B PSRAM;只在分钟或电池状态
改变时使图层失效并刷新,眨眼仍只改变眼睛网格,不解码新 PNG。
- PNG 表情复用相同点阵 / 电池绘制器,在每帧合成时更新;二维码和 LCD
边界测试页面不显示状态栏。PNG 无限等待期间不会独立重绘状态栏。
- 不需要字体包或新图片。旧 PNG 低电量底部告警保留;旧充电 PNG 不再自动
叠加,避免与数字电量冲突。GFX 待机低电量以红色电池表示。
## 验证
`python3 tools/test_lcd_status_hud.py` 从实际源码提取绘制函数,用 Clang
AddressSanitizer / UndefinedBehaviorSanitizer 检查 808 组有效性、充电、
低电量、0..100% 组合,验证行块和整帧等价、边界及颜色。
使用 ESP-IDF v5.5.4 执行 idf.py build。实机需验证:
1. 联网后 --:-- 变成本地时间,跨分钟更新;未同步不显示伪造时间。
2. 插拔充电线、电量变化时状态更新,眼睛位置 / 眨眼无变化或残影。
3. 各有限动画、Wi-Fi 恢复、PTT 结束、OTA 退出及服务错误后回 GFX 待机。
4. 动画结束同时到达新请求时不被自动 IDLE 覆盖。
编译 / 主机测试不等同于上述实机验证。
+68
View File
@@ -0,0 +1,68 @@
from pathlib import Path
import subprocess, tempfile, re
src=(Path(__file__).resolve().parents[1]/'components/platform/src/lcd_direct.c').read_text()
def function(name):
# Locate definition rather than the forward declaration.
matches=list(re.finditer(r'static [^;{}]+\b'+name+r'\([^;{}]*\)\s*\{',src))
assert len(matches)==1,name
start=matches[0].start(); p=src.index('{',start); depth=1; end=p+1
while depth:
if src[end]=='{':depth+=1
if src[end]=='}':depth-=1
end+=1
return src[start:end]
table=re.search(r'static const uint8_t s_clock_glyph_rows.*?\n};',src,re.S).group(0)
prefix='''#include <stdint.h>
#include <stdbool.h>
#include <stdio.h>
#include <string.h>
#include <assert.h>
#define LCD_DIRECT_WIDTH 320
#define LCD_DIRECT_CLOCK_GLYPH_HEIGHT 7
#define CONFIG_TQ_SCREEN_SWAP_COLOR_BYTES 1
#define portENTER_CRITICAL(x) ((void)0)
#define portEXIT_CRITICAL(x) ((void)0)
typedef struct {bool low_battery_visible, charging_visible, battery_valid; uint8_t battery_percent;} platform_lcd_status_overlay_t;
static platform_lcd_status_overlay_t s_status_overlay;
#define PLATFORM_LCD_PRESENTATION_IDLE 0
typedef struct { int kind; } lcd_direct_request_t;
static lcd_direct_request_t s_pending;
static bool s_pending_valid, s_stop_requested;
'''
test='''int main(void) {
uint16_t full[320*240+2], blocks[320*240+2];
for (unsigned valid=0;valid<2;++valid)
for (unsigned charge=0;charge<2;++charge)
for (unsigned low=0;low<2;++low)
for (unsigned percent=0;percent<=100;++percent) {
s_status_overlay=(platform_lcd_status_overlay_t){low,charge,valid,percent};
for (unsigned i=0;i<320*240+2;++i) full[i]=blocks[i]=0x1234;
lcd_direct_overlay_battery(full+1,0,240);
for(unsigned row=0;row<240;row+=8)
lcd_direct_overlay_battery(blocks+1+row*320,row,8);
assert(memcmp(full,blocks,sizeof(full))==0);
assert(full[0]==0x1234 && full[320*240+1]==0x1234);
for(unsigned y=0;y<240;++y) for(unsigned x=0;x<320;++x) {
if(x<212 || y<13 || y>=34) assert(full[1+y*320+x]==0x1234);
}
uint16_t expected=lcd_direct_swap_color(charge?0x07e0:low?0xf800:0xffff);
assert(full[1+14*320+288]==expected);
unsigned fill=percent*16/100;
if(valid && !charge && fill) assert(full[1+17*320+291]==expected);
if(!valid) assert(full[1+17*320+291]==0);
}
for (unsigned pending=0;pending<2;++pending)
for (unsigned stopping=0;stopping<2;++stopping) {
s_pending=(lcd_direct_request_t){99};
s_pending_valid=pending; s_stop_requested=stopping;
lcd_direct_queue_idle_if_available();
assert(s_pending.kind==((pending || stopping)?99:PLATFORM_LCD_PRESENTATION_IDLE));
assert(s_pending_valid==(pending || !stopping));
}
puts("PASS: idle queue preserves pending requests and stop; 808 battery combinations; full-frame/8-row equivalence, bounds, colors and fill");
}'''
with tempfile.TemporaryDirectory(prefix='lucky-hud-') as tmp:
c=Path(tmp)/'hud.c'; exe=Path(tmp)/'hud'
c.write_text(prefix+table+'\n'+'\n'.join(function(n) for n in ['lcd_direct_swap_color','lcd_direct_clock_glyph_index','lcd_direct_hud_pixel','lcd_direct_overlay_battery','lcd_direct_queue_idle_if_available'])+'\n'+test)
subprocess.run(['clang','-std=c11','-fsanitize=address,undefined','-g',str(c),'-o',str(exe)],check=True)
subprocess.run([str(exe)],check=True)