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

531 lines
18 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
# 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,并将校时从每日运势业务中提升为联网后的通用设备能力。
当前软件构建验证已经完成;最终上线前仍需要烧录设备,对显示位置、背景对比度、分钟跳变和断网重连场景进行实机验证。