Files
lucky-cat/docs/knowledge vault/lcd-wall-clock-display.zh-CN.md

18 KiB
Raw Permalink Blame History

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

具体策略:

  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 增加两个通用配置:

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 像素。二维码已经排除,但其他业务图片仍会显示时间。

后续硬件验证应确认:

  1. 基础表情下的可读性;
  2. 浅色背景上的黑色阴影是否足够;
  3. 是否遮挡五行、抽签、OTA 或财位画面的重要内容;
  4. 左上角位置和字号是否符合最终 UI 设计;
  5. 充电图标、低电量图标与时间是否同时正常显示。

7. 结论

本次实现复用了已有 SNTP 和 LCD 直绘架构,用 84 字节点阵字模解决了无 LVGL 环境下的时间显示问题。方案没有新增动态内存、PSRAM、任务或 framebuffer,并将校时从每日运势业务中提升为联网后的通用设备能力。

当前软件构建验证已经完成;最终上线前仍需要烧录设备,对显示位置、背景对比度、分钟跳变和断网重连场景进行实机验证。