# LCD 直绘时钟显示实现说明 ## 1. 文档范围 本文整理提交 `96e975d feat(lcd): display synchronized wall clock` 的问题原因、解决方式、关键代码、验证结果和后续影响。 本次功能在不引入 LVGL 的前提下,为 320x240 LCD 增加左上角 `HH:MM` 时间显示。设备尚未完成网络校时的时候显示 `--:--`,二维码页面不显示时间。 ## 2. 问题原因 ### 2.1 项目没有文字渲染能力 项目当前直接通过 ESP-IDF `esp_lcd` 驱动 ST7789P3,LCD 渲染链路主要支持: - 从应用镜像内嵌素材包读取 PNG; - 将 PNG 解码为 RGB565; - 按 8 行一组复制到 LCD DMA 行缓冲; - 合成充电、低电量等状态图标; - 通过 SPI 将行缓冲发送到屏幕。 项目没有 LVGL,也没有通用字体库,因此不能直接使用标签控件显示 `HH:MM`。如果为了五个字符引入完整 GUI 框架,会额外增加固件、RAM、任务和迁移成本。 ### 2.2 校时启动依赖每日运势流程 项目原本已经具备 SNTP 校时接口,但校时启动位于每日运势任务中。每日运势任务会先读取出生资料;没有出生资料时,任务会提前等待,不会执行后面的 SNTP 启动逻辑。 因此原有逻辑不能保证设备只要联网就具备有效系统时间。LCD 时钟如果直接读取 `time()`,可能长期得到未校准的启动相对时间。 ### 2.3 时间配置属于特定业务功能 原 SNTP 服务器配置名为 `FORTUNE_SNTP_SERVER`,只在每日运势功能开启时出现。墙上时钟属于设备通用能力,不应依赖运势功能是否启用。 ### 2.4 内部 RAM 需要保持低占用 LCD 当前已经使用以下内部 DMA 行缓冲: ```c #define LCD_DIRECT_ROWS_PER_TRANSFER 8U #define LCD_DIRECT_PIXELS_PER_TRANSFER \ (LCD_DIRECT_WIDTH * LCD_DIRECT_ROWS_PER_TRANSFER) static uint16_t s_line_buffer[LCD_DIRECT_PIXELS_PER_TRANSFER]; ``` 逻辑宽度为 320 像素,因此该缓冲大小为: ```text 320 x 8 x 2 = 5120 字节 ``` 如果再创建完整的 320x240 RGB565 framebuffer,需要额外占用 153600 字节;如果新建独立时间任务,还要增加任务栈。这两种方式都没有必要。 ## 3. 解决方式 本次采用“通用联网校时 + Flash 点阵字模 + 复用 LCD 行缓冲”的方案。 完整数据流如下: ```text Wi-Fi 获得 IP -> 启动幂等 SNTP 客户端 -> SNTP 更新系统 wall clock -> LCD 渲染当前 PNG 或测试图 -> 读取本地 HH:MM -> 将点阵字符写入现有 8 行 RGB565 缓冲 -> 合成电池状态图标 -> SPI 发送到 ST7789P3 ``` 具体策略: 1. 新增设备通用 SNTP 服务器和 POSIX 时区配置。 2. Wi-Fi 状态任务启动时如果已经联网,立即启动校时。 3. Wi-Fi 从断开变为连接时,再次调用幂等校时启动接口。 4. 每日运势调度复用同一套通用时间配置。 5. 使用 5x7、1 bit 点阵保存 `0-9`、冒号和横线。 6. 点阵放大 4 倍,绘制白色主体和 1 像素黑色阴影。 7. 未校时或本地时间转换失败时显示 `--:--`。 8. 时间直接合成到现有行缓冲,不分配新画布。 9. 二维码页面跳过时间合成,避免影响扫码。 ## 4. 具体代码 ### 4.1 通用时间配置 Kconfig 增加两个通用配置: ```kconfig config TQ_TIME_SNTP_SERVER string "Wall-clock SNTP server" default "ntp.aliyun.com" help SNTP server used to establish wall-clock time after Wi-Fi obtains an IP address. config TQ_TIME_POSIX_TZ string "Wall-clock POSIX time zone" default "CST-8" help POSIX TZ string used for local wall-clock display and daily scheduling. For China Standard Time use "CST-8". ``` 默认配置: ```ini CONFIG_TQ_TIME_SNTP_SERVER="ntp.aliyun.com" CONFIG_TQ_TIME_POSIX_TZ="CST-8" ``` 代码保留编译期兜底值,避免某些旧配置环境缺少新符号时无法构建: ```c #ifndef CONFIG_TQ_TIME_SNTP_SERVER #define CONFIG_TQ_TIME_SNTP_SERVER "ntp.aliyun.com" #endif #ifndef CONFIG_TQ_TIME_POSIX_TZ #define CONFIG_TQ_TIME_POSIX_TZ "CST-8" #endif ``` 原 `FORTUNE_SNTP_SERVER` 被移除,每日运势和 LCD 时钟统一使用 `TQ_TIME_*` 配置。 ### 4.2 Wi-Fi 联网后独立启动校时 新增通用启动函数: ```c static void control_plane_start_wall_clock_sync(void) { esp_err_t err = domain_runtime_time_sync_start(CONFIG_TQ_TIME_SNTP_SERVER, CONFIG_TQ_TIME_POSIX_TZ); if (err != ESP_OK) { ESP_LOGW(TAG, "Start wall-clock synchronization failed: %s", esp_err_to_name(err)); } } ``` Wi-Fi 状态任务启动时,如果 STA 已经连接,则在启动 WebSocket 和用户配置服务之前启动校时: ```c if (!was_connected) { /* 保持原有离线处理。 */ } else { control_plane_start_wall_clock_sync(); (void)control_plane_schedule_websocket_wait_task(); esp_err_t web_err = control_plane_ensure_user_config_server(); /* 保持原有错误处理。 */ } ``` 运行过程中检测到 Wi-Fi 恢复时,再次启动校时: ```c if (connected && (!was_connected || disconnected_expression_active)) { ESP_LOGI(TAG, "Wi-Fi STA connected; restoring idle expression"); control_plane_start_wall_clock_sync(); control_plane_request_wifi_state_expression( DOMAIN_EXPRESSION_IDLE, DOMAIN_EXPRESSION_TRIGGER_WIFI_RECONNECTED, "Wi-Fi reconnected"); (void)control_plane_schedule_websocket_wait_task(); } ``` 底层 `domain_runtime_time_sync_start()` 最终调用幂等的 `platform_time_sync_start()`。SNTP 已经启动时,重复调用只会重新应用时区并返回成功,不会创建多个 SNTP 客户端。 每日运势等待有效时间时也改为使用通用配置: ```c static esp_err_t control_plane_wait_for_synced_time(void) { esp_err_t err = domain_runtime_time_sync_start(CONFIG_TQ_TIME_SNTP_SERVER, CONFIG_TQ_TIME_POSIX_TZ); if (err != ESP_OK) { return err; } /* 保持原有的联网状态、停止请求和超时检查。 */ } ``` ### 4.3 时钟布局参数 时钟从屏幕左上角 `(12, 10)` 开始绘制。每个点阵像素放大为 4x4 实际像素,字符步进为 24 像素: ```c #define LCD_DIRECT_CLOCK_X 12U #define LCD_DIRECT_CLOCK_Y 10U #define LCD_DIRECT_CLOCK_GLYPH_WIDTH 5U #define LCD_DIRECT_CLOCK_GLYPH_HEIGHT 7U #define LCD_DIRECT_CLOCK_GLYPH_SCALE 4U #define LCD_DIRECT_CLOCK_GLYPH_ADVANCE 24U #define LCD_DIRECT_CLOCK_TEXT_LENGTH 5U #define LCD_DIRECT_CLOCK_OUTLINE_PX 1 #define LCD_DIRECT_CLOCK_COLOR 0xFFFFU #define LCD_DIRECT_CLOCK_OUTLINE_COLOR 0x0000U ``` 五个字符的实际覆盖区域约为: ```text x: 12~128 y: 10~38 ``` ### 4.4 Flash 点阵字模 每行仅使用低 5 bit 表示亮点,数组声明为 `static const`,由链接器放入只读区: ```c static const uint8_t s_clock_glyph_rows[][LCD_DIRECT_CLOCK_GLYPH_HEIGHT] = { {0x0E, 0x11, 0x13, 0x15, 0x19, 0x11, 0x0E}, /* 0 */ {0x04, 0x0C, 0x04, 0x04, 0x04, 0x04, 0x0E}, /* 1 */ {0x0E, 0x11, 0x01, 0x02, 0x04, 0x08, 0x1F}, /* 2 */ {0x1E, 0x01, 0x01, 0x0E, 0x01, 0x01, 0x1E}, /* 3 */ {0x02, 0x06, 0x0A, 0x12, 0x1F, 0x02, 0x02}, /* 4 */ {0x1F, 0x10, 0x10, 0x1E, 0x01, 0x01, 0x1E}, /* 5 */ {0x0E, 0x10, 0x10, 0x1E, 0x11, 0x11, 0x0E}, /* 6 */ {0x1F, 0x01, 0x02, 0x04, 0x08, 0x08, 0x08}, /* 7 */ {0x0E, 0x11, 0x11, 0x0E, 0x11, 0x11, 0x0E}, /* 8 */ {0x0E, 0x11, 0x11, 0x0F, 0x01, 0x01, 0x0E}, /* 9 */ {0x00, 0x04, 0x04, 0x00, 0x04, 0x04, 0x00}, /* : */ {0x00, 0x00, 0x00, 0x1F, 0x00, 0x00, 0x00}, /* - */ }; ``` 整个数组为 12x7,即 84 字节。横线用于组成未校时状态 `--:--`。 字符到字模索引的映射: ```c static size_t lcd_direct_clock_glyph_index(char character) { if (character >= '0' && character <= '9') { return (size_t)(character - '0'); } return character == ':' ? 10U : 11U; } ``` ### 4.5 获取显示文本 ```c static void lcd_direct_clock_text(char text[LCD_DIRECT_CLOCK_TEXT_LENGTH + 1U]) { if (!platform_time_is_synced()) { memcpy(text, "--:--", LCD_DIRECT_CLOCK_TEXT_LENGTH + 1U); return; } time_t now = time(NULL); struct tm local_tm = {0}; if (localtime_r(&now, &local_tm) == NULL) { memcpy(text, "--:--", LCD_DIRECT_CLOCK_TEXT_LENGTH + 1U); return; } text[0] = (char)('0' + local_tm.tm_hour / 10); text[1] = (char)('0' + local_tm.tm_hour % 10); text[2] = ':'; text[3] = (char)('0' + local_tm.tm_min / 10); text[4] = (char)('0' + local_tm.tm_min % 10); text[5] = '\0'; } ``` 这里直接生成固定长度字符串,没有使用动态内存,也没有引入通用格式化缓冲。 ### 4.6 将字符绘制到现有行缓冲 绘制函数只处理与当前 `row_start~row_start+row_count` 相交的点阵行: ```c static void lcd_direct_draw_clock_pass( uint16_t *pixel_buffer, uint16_t row_start, uint16_t row_count, const char text[LCD_DIRECT_CLOCK_TEXT_LENGTH + 1U], int x_offset, int y_offset, uint16_t color) { const int row_end = (int)row_start + (int)row_count; for (size_t character_index = 0; character_index < LCD_DIRECT_CLOCK_TEXT_LENGTH; ++character_index) { const uint8_t *glyph = s_clock_glyph_rows[ lcd_direct_clock_glyph_index(text[character_index])]; const int glyph_x = (int)LCD_DIRECT_CLOCK_X + (int)(character_index * LCD_DIRECT_CLOCK_GLYPH_ADVANCE) + x_offset; const int glyph_y = (int)LCD_DIRECT_CLOCK_Y + y_offset; for (uint16_t source_y = 0; source_y < LCD_DIRECT_CLOCK_GLYPH_HEIGHT; ++source_y) { for (uint16_t scale_y = 0; scale_y < LCD_DIRECT_CLOCK_GLYPH_SCALE; ++scale_y) { const int destination_y = glyph_y + (int)(source_y * LCD_DIRECT_CLOCK_GLYPH_SCALE + scale_y); if (destination_y < (int)row_start || destination_y >= row_end) { continue; } for (uint16_t source_x = 0; source_x < LCD_DIRECT_CLOCK_GLYPH_WIDTH; ++source_x) { const uint8_t mask = 1U << (LCD_DIRECT_CLOCK_GLYPH_WIDTH - 1U - source_x); if ((glyph[source_y] & mask) == 0U) { continue; } for (uint16_t scale_x = 0; scale_x < LCD_DIRECT_CLOCK_GLYPH_SCALE; ++scale_x) { const int destination_x = glyph_x + (int)(source_x * LCD_DIRECT_CLOCK_GLYPH_SCALE + scale_x); if (destination_x < 0 || destination_x >= (int)LCD_DIRECT_WIDTH) { continue; } const uint32_t destination_index = (uint32_t)(destination_y - (int)row_start) * LCD_DIRECT_WIDTH + (uint32_t)destination_x; pixel_buffer[destination_index] = lcd_direct_swap_color(color); } } } } } } ``` 时钟分两次绘制,先画右下偏移 1 像素的黑色阴影,再画白色主体: ```c static void lcd_direct_overlay_clock( uint16_t *pixel_buffer, uint16_t row_start, uint16_t row_count, const char text[LCD_DIRECT_CLOCK_TEXT_LENGTH + 1U]) { if (pixel_buffer == NULL || text == NULL) { return; } lcd_direct_draw_clock_pass(pixel_buffer, row_start, row_count, text, LCD_DIRECT_CLOCK_OUTLINE_PX, LCD_DIRECT_CLOCK_OUTLINE_PX, LCD_DIRECT_CLOCK_OUTLINE_COLOR); lcd_direct_draw_clock_pass(pixel_buffer, row_start, row_count, text, 0, 0, LCD_DIRECT_CLOCK_COLOR); } ``` ### 4.7 合成到 PNG 和测试图 绘制 PNG 前生成一次当前帧的时钟文本;二维码路径直接关闭时钟: ```c const bool clock_visible = strstr(image->path, "/qrcode.png") == NULL; char clock_text[LCD_DIRECT_CLOCK_TEXT_LENGTH + 1U] = {0}; if (clock_visible) { lcd_direct_clock_text(clock_text); } ``` 每组 8 行完成背景复制后,先合成时钟,再合成状态图标,最后发送 LCD: ```c if (clock_visible) { 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); err = lcd_direct_draw_rows(row_start, row_count); ``` 测试图和转轮占位画面也使用同一个时钟合成函数: ```c char clock_text[LCD_DIRECT_CLOCK_TEXT_LENGTH + 1U] = {0}; lcd_direct_clock_text(clock_text); for (uint16_t row_start = 0; row_start < LCD_DIRECT_HEIGHT; row_start += LCD_DIRECT_ROWS_PER_TRANSFER) { /* 生成当前测试图的 8 行背景。 */ lcd_direct_overlay_clock(s_line_buffer, row_start, row_count, clock_text); ESP_RETURN_ON_ERROR(lcd_direct_draw_rows(row_start, row_count), TAG, "write pixels failed"); } ``` ## 5. 解决成果 ### 5.1 功能成果 | 场景 | 最终行为 | | --- | --- | | 启动后尚未校时 | 左上角显示 `--:--` | | Wi-Fi 已连接 | 自动启动 SNTP,不要求存在出生资料 | | SNTP 校时成功 | 显示本地 24 小时制 `HH:MM` | | Wi-Fi 临时断开 | 系统时钟继续运行,保留时间显示 | | Wi-Fi 重连 | 幂等调用校时启动接口 | | 普通 PNG 表情 | 合成时间和原有状态图标 | | 测试图/转轮占位图 | 同样显示时间 | | 二维码页面 | 隐藏时间,避免遮挡二维码 | ### 5.2 内存成果 - 没有新增动态内存分配。 - 没有新增 framebuffer。 - 没有新增 FreeRTOS 任务、定时器或任务栈。 - 继续复用原有 5120 字节 LCD DMA 行缓冲。 - 点阵字模为 84 字节,并经目标文件符号检查确认位于只读区。 - 每次绘制只增加 6 字节时间字符串、`struct tm` 和少量局部变量,使用现有 LCD 渲染任务栈。 - 新增 PSRAM 占用为 0。 ### 5.3 构建成果 使用 ESP-IDF 5.5.4 执行重新配置和完整构建,结果成功: ```text TQ_Cat_Code.bin binary size 0x214200 bytes Smallest app partition is 0x380000 bytes 0x16be00 bytes (41%) free ``` 新配置成功进入生成配置: ```c #define CONFIG_TQ_TIME_SNTP_SERVER "ntp.aliyun.com" #define CONFIG_TQ_TIME_POSIX_TZ "CST-8" ``` ## 6. 后续影响与注意事项 ### 6.1 每日运势配置发生迁移 原配置: ```ini CONFIG_FORTUNE_SNTP_SERVER="ntp.aliyun.com" ``` 已经改为: ```ini CONFIG_TQ_TIME_SNTP_SERVER="ntp.aliyun.com" CONFIG_TQ_TIME_POSIX_TZ="CST-8" ``` 如果外部构建脚本、私有 `sdkconfig.defaults` 或生产配置仍设置 `CONFIG_FORTUNE_SNTP_SERVER`,需要迁移到新名称。每日运势请求中发送给服务器的 IANA 时区 `CONFIG_FORTUNE_TIMEZONE="Asia/Shanghai"` 没有改变。 ### 6.2 SNTP 会更早产生一次网络请求 校时现在在 Wi-Fi 可用后立即启动,不再等待出生资料。这会在联网阶段增加正常的 SNTP UDP 请求,但不会增加常驻工作任务,也不会重复创建 SNTP 客户端。 ### 6.3 POSIX 时区是进程级全局状态 `platform_time_sync_start()` 使用 `setenv("TZ", ...)` 和 `tzset()` 设置进程时区。LCD 时钟和每日运势的本地午夜计算会共享同一时区,这是当前统一配置的预期行为。 如果未来设备需要同时处理多个用户时区,不能反复修改进程级 `TZ`;届时需要引入显式时区转换方案。 ### 6.4 没有外置 RTC 的断电限制 设备正常运行期间,即使 Wi-Fi 断开,系统时钟仍会继续走。但是彻底断电后,如果开机时无法联网,设备不能恢复准确绝对时间,会继续显示 `--:--`。 如果产品要求断电、无网启动后立即显示准确时间,需要增加带后备电池的外置 RTC,例如 DS3231。 ### 6.5 时间刷新依赖画面重绘 当前基础表情和动画会持续重绘,因此分钟变化会在后续帧中反映出来。当前没有单独创建每分钟刷新定时器。 如果未来增加“绘制一次后长期不再刷新的静态画面”,需要在分钟变化时通知 LCD 渲染任务重新绘制当前帧,否则该静态画面上的时间不会自动变化。 ### 6.6 每帧会读取一次本地时间 每个完整画面生成一次 `HH:MM` 文本,不会在每组 8 行中重复调用 `localtime_r()`。现有刷新频率下开销较低。 如果后续提高 LCD 帧率或增加更多文字,可缓存当前分钟,仅在分钟变化时重新转换本地时间,以进一步减少 CPU 开销。 ### 6.7 UI 仍需实机确认 当前时钟使用白色像素字和右下黑色阴影,覆盖范围约为 `117x29` 像素。二维码已经排除,但其他业务图片仍会显示时间。 后续硬件验证应确认: 1. 基础表情下的可读性; 2. 浅色背景上的黑色阴影是否足够; 3. 是否遮挡五行、抽签、OTA 或财位画面的重要内容; 4. 左上角位置和字号是否符合最终 UI 设计; 5. 充电图标、低电量图标与时间是否同时正常显示。 ## 7. 结论 本次实现复用了已有 SNTP 和 LCD 直绘架构,用 84 字节点阵字模解决了无 LVGL 环境下的时间显示问题。方案没有新增动态内存、PSRAM、任务或 framebuffer,并将校时从每日运势业务中提升为联网后的通用设备能力。 当前软件构建验证已经完成;最终上线前仍需要烧录设备,对显示位置、背景对比度、分钟跳变和断网重连场景进行实机验证。