18 KiB
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 行缓冲:
#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 像素,因此该缓冲大小为:
320 x 8 x 2 = 5120 字节
如果再创建完整的 320x240 RGB565 framebuffer,需要额外占用 153600 字节;如果新建独立时间任务,还要增加任务栈。这两种方式都没有必要。
3. 解决方式
本次采用“通用联网校时 + Flash 点阵字模 + 复用 LCD 行缓冲”的方案。
完整数据流如下:
Wi-Fi 获得 IP
-> 启动幂等 SNTP 客户端
-> SNTP 更新系统 wall clock
-> LCD 渲染当前 PNG 或测试图
-> 读取本地 HH:MM
-> 将点阵字符写入现有 8 行 RGB565 缓冲
-> 合成电池状态图标
-> SPI 发送到 ST7789P3
具体策略:
- 新增设备通用 SNTP 服务器和 POSIX 时区配置。
- Wi-Fi 状态任务启动时如果已经联网,立即启动校时。
- Wi-Fi 从断开变为连接时,再次调用幂等校时启动接口。
- 每日运势调度复用同一套通用时间配置。
- 使用 5x7、1 bit 点阵保存
0-9、冒号和横线。 - 点阵放大 4 倍,绘制白色主体和 1 像素黑色阴影。
- 未校时或本地时间转换失败时显示
--:--。 - 时间直接合成到现有行缓冲,不分配新画布。
- 二维码页面跳过时间合成,避免影响扫码。
4. 具体代码
4.1 通用时间配置
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".
默认配置:
CONFIG_TQ_TIME_SNTP_SERVER="ntp.aliyun.com"
CONFIG_TQ_TIME_POSIX_TZ="CST-8"
代码保留编译期兜底值,避免某些旧配置环境缺少新符号时无法构建:
#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 联网后独立启动校时
新增通用启动函数:
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 和用户配置服务之前启动校时:
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 恢复时,再次启动校时:
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 客户端。
每日运势等待有效时间时也改为使用通用配置:
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 像素:
#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
五个字符的实际覆盖区域约为:
x: 12~128
y: 10~38
4.4 Flash 点阵字模
每行仅使用低 5 bit 表示亮点,数组声明为 static const,由链接器放入只读区:
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 字节。横线用于组成未校时状态 --:--。
字符到字模索引的映射:
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 获取显示文本
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 相交的点阵行:
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 像素的黑色阴影,再画白色主体:
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 前生成一次当前帧的时钟文本;二维码路径直接关闭时钟:
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:
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);
测试图和转轮占位画面也使用同一个时钟合成函数:
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 执行重新配置和完整构建,结果成功:
TQ_Cat_Code.bin binary size 0x214200 bytes
Smallest app partition is 0x380000 bytes
0x16be00 bytes (41%) free
新配置成功进入生成配置:
#define CONFIG_TQ_TIME_SNTP_SERVER "ntp.aliyun.com"
#define CONFIG_TQ_TIME_POSIX_TZ "CST-8"
6. 后续影响与注意事项
6.1 每日运势配置发生迁移
原配置:
CONFIG_FORTUNE_SNTP_SERVER="ntp.aliyun.com"
已经改为:
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 像素。二维码已经排除,但其他业务图片仍会显示时间。
后续硬件验证应确认:
- 基础表情下的可读性;
- 浅色背景上的黑色阴影是否足够;
- 是否遮挡五行、抽签、OTA 或财位画面的重要内容;
- 左上角位置和字号是否符合最终 UI 设计;
- 充电图标、低电量图标与时间是否同时正常显示。
7. 结论
本次实现复用了已有 SNTP 和 LCD 直绘架构,用 84 字节点阵字模解决了无 LVGL 环境下的时间显示问题。方案没有新增动态内存、PSRAM、任务或 framebuffer,并将校时从每日运势业务中提升为联网后的通用设备能力。
当前软件构建验证已经完成;最终上线前仍需要烧录设备,对显示位置、背景对比度、分钟跳变和断网重连场景进行实机验证。